feat: Phase 2 data aggregation pipeline (Strike 4A)

Backend:
- Stats ingestion consumer subscribing to corrosion.*.stats NATS subject
- Hourly aggregation scheduler (runs :05 past every hour)
- Daily cleanup job (03:00 UTC) with 7-day raw / 90-day hourly retention
- Analytics API (summary, timeseries, CSV export)
- Complete stats DB queries with aggregation and cleanup

Frontend:
- Analytics dashboard with ECharts integration
- Player count and server performance charts
- Time range selector (24h/7d/30d)
- CSV export functionality
- Real-time data loading

Infrastructure:
- Exposed NatsBridge.jetstream for consumer access
- Background service initialization in main.rs

Data flow: Plugin → NATS → Consumer → DB → Aggregation → API → Charts

Unblocks Strike 4B (dashboards) and 4C (alerting).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 12:53:25 -05:00
parent 81eeb3b451
commit 75d08aeee4
11 changed files with 1130 additions and 73 deletions

View File

@@ -0,0 +1,199 @@
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::stats;
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))
}
/// 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())
}