use std::sync::Arc; use axum::{ extract::{Query, State}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use serde::{Deserialize, Serialize}; use crate::db::{maps, stats, wipes}; use crate::middleware::auth::AuthUser; use crate::models::error::{ApiError, ApiResult}; use crate::AppState; pub fn router() -> Router> { Router::new() .route("/summary", get(get_summary)) .route("/timeseries", get(get_timeseries)) .route("/export", get(export_csv)) .route("/wipes/performance", get(get_wipe_performance)) } /// Query parameters for analytics endpoints. #[derive(Debug, Deserialize)] struct AnalyticsQuery { /// Time range in hours (default: 24) #[serde(default = "default_range")] range: i64, /// Granularity: "raw" or "hourly" (default: "hourly") #[serde(default = "default_granularity")] granularity: String, } fn default_range() -> i64 { 24 } fn default_granularity() -> String { "hourly".to_string() } /// GET /api/analytics/summary?range=7d /// Returns peak players, avg players, uptime percentage. async fn get_summary( auth: AuthUser, State(state): State>, Query(query): Query, ) -> ApiResult> { let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?; let summary = stats::get_analytics_summary(&state.db, license_id, query.range) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(summary)) } /// GET /api/analytics/timeseries?range=24&granularity=hourly /// Returns time-series data for charting. #[derive(Serialize)] struct TimeseriesResponse { timestamps: Vec, player_count: Vec, fps: Vec, entity_count: Vec, memory_usage_mb: Vec, } async fn get_timeseries( auth: AuthUser, State(state): State>, Query(query): Query, ) -> ApiResult> { let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?; if query.granularity == "hourly" { // Use hourly aggregates let hourly_stats = stats::get_hourly_stats(&state.db, license_id, query.range) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let timestamps: Vec = hourly_stats .iter() .map(|s| s.hour.to_rfc3339()) .collect(); let player_count: Vec = hourly_stats .iter() .map(|s| s.max_players) .collect(); let fps: Vec = hourly_stats .iter() .map(|s| s.avg_fps) .collect(); let entity_count: Vec = hourly_stats .iter() .map(|s| s.avg_entities) .collect(); // Hourly stats don't track memory, return zeros let memory_usage_mb: Vec = vec![0; hourly_stats.len()]; Ok(Json(TimeseriesResponse { timestamps, player_count, fps, entity_count, memory_usage_mb, })) } else { // Use raw stats (default limit: 1000 samples) let limit = (query.range * 60).min(1000); // 1 sample per minute, max 1000 let raw_stats = stats::get_recent_stats(&state.db, license_id, limit) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let timestamps: Vec = raw_stats .iter() .map(|s| s.recorded_at.to_rfc3339()) .collect(); let player_count: Vec = raw_stats .iter() .map(|s| s.player_count) .collect(); let fps: Vec = raw_stats .iter() .map(|s| s.fps) .collect(); let entity_count: Vec = raw_stats .iter() .map(|s| s.entity_count) .collect(); let memory_usage_mb: Vec = raw_stats .iter() .map(|s| s.memory_usage_mb) .collect(); Ok(Json(TimeseriesResponse { timestamps, player_count, fps, entity_count, memory_usage_mb, })) } } /// GET /api/analytics/export?range=168 /// Export stats as CSV. async fn export_csv( auth: AuthUser, State(state): State>, Query(query): Query, ) -> Result { let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?; // Get raw stats for CSV export let limit = (query.range * 60).min(10000); // Max 10k rows let raw_stats = stats::get_recent_stats(&state.db, license_id, limit) .await .map_err(|e| ApiError::Internal(e.to_string()))?; // Build CSV let mut csv = String::from("timestamp,player_count,max_players,fps,entity_count,uptime_seconds,memory_usage_mb\n"); for stat in raw_stats.iter().rev() { // Reverse to chronological order csv.push_str(&format!( "{},{},{},{:.2},{},{},{}\n", stat.recorded_at.to_rfc3339(), stat.player_count, stat.max_players, stat.fps, stat.entity_count, stat.uptime_seconds, stat.memory_usage_mb )); } // Return CSV response Ok(( StatusCode::OK, [ (header::CONTENT_TYPE, "text/csv"), ( header::CONTENT_DISPOSITION, "attachment; filename=\"server_stats.csv\"", ), ], csv, ) .into_response()) } // ======================================================================== // WIPE ANALYTICS — Phase 2 // ======================================================================== /// Query parameters for wipe analytics. #[derive(Debug, Deserialize)] struct WipeAnalyticsQuery { /// Time range in days (default: 90) #[serde(default = "default_wipe_range")] range: String, } fn default_wipe_range() -> String { "90d".to_string() } /// Response for wipe performance analytics. #[derive(Debug, Serialize)] struct WipePerformanceResponse { total_wipes: i64, successful_wipes: i64, failed_wipes: i64, success_rate_percent: f64, avg_duration_seconds: f64, wipes: Vec, population_curve: wipes::PopulationCurve, optimal_wipe_day: String, optimal_wipe_hour: i32, } /// GET /api/analytics/wipes/performance?range=90d /// Returns wipe performance metrics: success rate, duration, population curves, optimal timing. async fn get_wipe_performance( auth: AuthUser, State(state): State>, Query(query): Query, ) -> ApiResult> { let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?; // Parse range string (e.g., "90d", "30d", "all") let days = parse_wipe_range(&query.range); // Query analytics let (total_wipes, successful_wipes, failed_wipes) = wipes::get_wipe_success_rate(&state.db, license_id, days) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let avg_duration_seconds = wipes::get_average_wipe_duration(&state.db, license_id, days) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let population_curve = wipes::get_population_curve_by_cycle(&state.db, license_id, days) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let optimal_timing = wipes::get_optimal_wipe_timing(&state.db, license_id, days) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let wipe_entries = wipes::get_wipe_analytics_entries(&state.db, license_id, days) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let success_rate_percent = if total_wipes > 0 { (successful_wipes as f64 / total_wipes as f64) * 100.0 } else { 0.0 }; Ok(Json(WipePerformanceResponse { total_wipes, successful_wipes, failed_wipes, success_rate_percent, avg_duration_seconds, wipes: wipe_entries, population_curve, optimal_wipe_day: optimal_timing.optimal_wipe_day, optimal_wipe_hour: optimal_timing.optimal_wipe_hour, })) } /// Parse wipe range string to days. fn parse_wipe_range(range: &str) -> i64 { if range == "all" { 365 * 10 // 10 years = effectively all } else if let Some(days_str) = range.strip_suffix('d') { days_str.parse::().unwrap_or(90) } else { 90 // Default to 90 days } }