Files
corrosion-admin-panel/backend/src/api/analytics.rs
Vantz Stockwell cef89ade18 feat: Implement map analytics system with effectiveness tracking
Add complete map analytics pipeline to answer: "Which maps drive the most
players? Is my rotation working?"

Backend Changes:
- Migration 005: Add map_id FK to server_stats and wipe_history tables
- Stats consumer now captures current_map_id when persisting stats
- Map analytics queries: get_map_analytics() returns performance metrics,
  effectiveness scores, and rotation health
- API endpoint: GET /api/analytics/maps?range=90d returns summary with
  best performing map and rotation effectiveness percentage

Frontend Changes:
- MapAnalyticsView.vue: Complete dashboard with performance charts,
  sortable metrics table, actionable insights, and CSV export
- ECharts bar chart comparing avg vs peak players per map
- Color-coded effectiveness scoring (green ≥80%, yellow ≥60%, red <60%)
- Time range selector: 30d/90d/all

Purpose: Enables data-driven map selection for wipe day based on player
engagement metrics. Rotation effectiveness algorithm scores maps by
(avg_players / peak_players) * 100.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 14:22:55 -05:00

296 lines
8.5 KiB
Rust

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<Arc<AppState>> {
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<Arc<AppState>>,
Query(query): Query<AnalyticsQuery>,
) -> ApiResult<Json<stats::AnalyticsSummary>> {
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<String>,
player_count: Vec<i32>,
fps: Vec<f64>,
entity_count: Vec<i32>,
memory_usage_mb: Vec<i32>,
}
async fn get_timeseries(
auth: AuthUser,
State(state): State<Arc<AppState>>,
Query(query): Query<AnalyticsQuery>,
) -> ApiResult<Json<TimeseriesResponse>> {
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<String> = hourly_stats
.iter()
.map(|s| s.hour.to_rfc3339())
.collect();
let player_count: Vec<i32> = hourly_stats
.iter()
.map(|s| s.max_players)
.collect();
let fps: Vec<f64> = hourly_stats
.iter()
.map(|s| s.avg_fps)
.collect();
let entity_count: Vec<i32> = hourly_stats
.iter()
.map(|s| s.avg_entities)
.collect();
// Hourly stats don't track memory, return zeros
let memory_usage_mb: Vec<i32> = 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<String> = raw_stats
.iter()
.map(|s| s.recorded_at.to_rfc3339())
.collect();
let player_count: Vec<i32> = raw_stats
.iter()
.map(|s| s.player_count)
.collect();
let fps: Vec<f64> = raw_stats
.iter()
.map(|s| s.fps)
.collect();
let entity_count: Vec<i32> = raw_stats
.iter()
.map(|s| s.entity_count)
.collect();
let memory_usage_mb: Vec<i32> = 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<Arc<AppState>>,
Query(query): Query<AnalyticsQuery>,
) -> Result<Response, ApiError> {
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<wipes::WipeAnalyticsEntry>,
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<Arc<AppState>>,
Query(query): Query<WipeAnalyticsQuery>,
) -> ApiResult<Json<WipePerformanceResponse>> {
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::<i64>().unwrap_or(90)
} else {
90 // Default to 90 days
}
}