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

@@ -242,4 +242,99 @@ impl SchedulerService {
Ok(next_times)
}
/// Register hourly stats aggregation job (runs at :05 past every hour).
pub async fn register_stats_aggregation(&self) -> Result<()> {
let db = self.db.clone();
let job = Job::new_async("0 5 * * * *", move |_uuid, _l| {
let db = db.clone();
Box::pin(async move {
tracing::info!("Running hourly stats aggregation");
// Get all active licenses
let licenses: Vec<(Uuid,)> = match sqlx::query_as(
"SELECT id FROM licenses WHERE status = 'active'",
)
.fetch_all(&db)
.await
{
Ok(l) => l,
Err(e) => {
tracing::error!("Failed to query active licenses: {}", e);
return;
}
};
tracing::info!("Aggregating stats for {} licenses", licenses.len());
for (license_id,) in licenses {
if let Err(e) = crate::db::stats::aggregate_hourly_stats(&db, license_id).await
{
tracing::error!(
"Failed to aggregate stats for license {}: {}",
license_id,
e
);
}
}
tracing::info!("Hourly stats aggregation complete");
})
})
.context("Failed to create stats aggregation job")?;
self.scheduler
.add(job)
.await
.context("Failed to add stats aggregation job to scheduler")?;
tracing::info!("Registered hourly stats aggregation job (cron: 0 5 * * * *)");
Ok(())
}
/// Register daily stats cleanup job (runs at 03:00 UTC).
pub async fn register_stats_cleanup(&self) -> Result<()> {
let db = self.db.clone();
let job = Job::new_async("0 0 3 * * *", move |_uuid, _l| {
let db = db.clone();
Box::pin(async move {
tracing::info!("Running daily stats cleanup");
// Delete raw stats older than 7 days
match crate::db::stats::cleanup_old_stats(&db, 7).await {
Ok(deleted) => {
tracing::info!("Deleted {} old raw stats records", deleted);
}
Err(e) => {
tracing::error!("Failed to cleanup old raw stats: {}", e);
}
}
// Delete hourly stats older than 90 days
match crate::db::stats::cleanup_old_hourly_stats(&db, 90).await {
Ok(deleted) => {
tracing::info!("Deleted {} old hourly stats records", deleted);
}
Err(e) => {
tracing::error!("Failed to cleanup old hourly stats: {}", e);
}
}
tracing::info!("Daily stats cleanup complete");
})
})
.context("Failed to create stats cleanup job")?;
self.scheduler
.add(job)
.await
.context("Failed to add stats cleanup job to scheduler")?;
tracing::info!("Registered daily stats cleanup job (cron: 0 0 3 * * *)");
Ok(())
}
}