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>
296 lines
8.5 KiB
Rust
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
|
|
}
|
|
}
|
|
|