feat: Implement Phase 2 wipe performance analytics dashboard

Complete implementation of wipe analytics system providing operational
insights and data-driven wipe timing optimization.

Backend:
- Added comprehensive analytics query layer to db/wipes.rs:
  - Success rate calculation over time ranges
  - Average wipe duration tracking
  - Post-wipe population curve analysis (Day 1/2/3)
  - Optimal wipe timing recommendations based on player peaks
  - Individual wipe entry tracking with peak population correlation
- Implemented GET /api/analytics/wipes/performance endpoint with
  flexible range parameters (6d/12d/90d/all)
- All queries leverage hourly aggregate tables for 90-day retention

Frontend:
- Built WipeAnalyticsView.vue with 3 ECharts visualizations:
  - Success rate timeline (scatter: green success, red failures)
  - Population curve comparing Day 1/2/3 post-wipe averages
  - Wipe duration trend showing execution time evolution
- Insight cards displaying success rate, avg duration, peak day, optimal timing
- Actionable recommendations banner with data-driven suggestions:
  - Optimal wipe scheduling based on historical player peaks
  - Wipe frequency recommendations (weekly vs bi-weekly)
  - Duration optimization alerts
  - Rollback protection warnings
- Time range selector and CSV export functionality
- Added /wipes/analytics route

TypeScript interfaces added: WipePerformanceMetrics, WipeAnalyticsEntry,
PopulationCurve

Answers critical operational questions: "How long do wipes take? When do
players peak post-wipe? What's my success rate? When should I wipe for
maximum population?"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 14:25:19 -05:00
parent dfa605f44f
commit 8790072609
3 changed files with 776 additions and 2 deletions

View File

@@ -1,10 +1,56 @@
use sqlx::PgPool;
use uuid::Uuid;
use anyhow::Result;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// TODO: Define WipeProfile struct (id, server_id, name, wipe_type, commands, plugin_actions, created_at)
// TODO: Define WipeSchedule struct (id, profile_id, cron_expression, next_run_at, enabled)
// TODO: Define WipeHistory struct (id, profile_id, started_at, completed_at, status, log)
/// Wipe history entry (full schema).
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct WipeHistoryRow {
pub id: Uuid,
pub license_id: Uuid,
pub wipe_schedule_id: Option<Uuid>,
pub wipe_profile_id: Uuid,
pub wipe_type: String,
pub trigger_type: String,
pub status: String,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub map_used: Option<String>,
pub plugins_wiped: Vec<String>,
pub plugins_preserved: Vec<String>,
pub backup_reference: Option<String>,
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
}
/// Analytics: Wipe with duration and peak population metadata.
#[derive(Debug, Clone, Serialize)]
pub struct WipeAnalyticsEntry {
pub date: DateTime<Utc>,
pub duration_seconds: i64,
pub peak_population: i32,
pub hours_to_peak: f64,
pub success: bool,
}
/// Analytics: Population curve aggregates (average players per day post-wipe).
#[derive(Debug, Clone, Serialize)]
pub struct PopulationCurve {
pub day_1_avg: f64,
pub day_2_avg: f64,
pub day_3_avg: f64,
}
/// Analytics: Optimal wipe timing recommendation.
#[derive(Debug, Clone, Serialize)]
pub struct OptimalWipeTiming {
pub optimal_wipe_day: String,
pub optimal_wipe_hour: i32,
}
/// Create a new wipe profile (template for a wipe operation).
pub async fn create_wipe_profile(pool: &PgPool, server_id: Uuid, name: &str, wipe_type: &str) -> Result<Uuid> {
@@ -40,3 +86,302 @@ pub async fn update_wipe_history(pool: &PgPool, history_id: Uuid, status: &str,
pub async fn get_wipe_history(pool: &PgPool, profile_id: Uuid, limit: i64) -> Result<()> {
todo!()
}
// ========================================================================
// ANALYTICS QUERIES — Phase 2 Wipe Performance Tracking
// ========================================================================
/// Get wipe success rate over a time range (days).
/// Returns (total_wipes, successful_wipes, failed_wipes).
pub async fn get_wipe_success_rate(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<(i64, i64, i64)> {
let result: (i64, i64, i64) = sqlx::query_as(
"SELECT
COUNT(*) as total_wipes,
COUNT(*) FILTER (WHERE status = 'success') as successful_wipes,
COUNT(*) FILTER (WHERE status IN ('failed', 'rolled_back')) as failed_wipes
FROM wipe_history
WHERE license_id = $1
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
AND status IN ('success', 'failed', 'rolled_back')",
)
.bind(license_id)
.bind(days)
.fetch_one(pool)
.await
.context("Failed to query wipe success rate")?;
Ok(result)
}
/// Get average wipe duration (in seconds) over a time range (days).
/// Only includes successful wipes with valid start/completion timestamps.
pub async fn get_average_wipe_duration(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<f64> {
let result: Option<(Option<f64>,)> = sqlx::query_as(
"SELECT AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration
FROM wipe_history
WHERE license_id = $1
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
AND status = 'success'
AND started_at IS NOT NULL
AND completed_at IS NOT NULL",
)
.bind(license_id)
.bind(days)
.fetch_optional(pool)
.await
.context("Failed to query average wipe duration")?;
Ok(result.and_then(|r| r.0).unwrap_or(0.0))
}
/// Get hours from wipe completion to peak population.
/// For each wipe, finds the max player count in the 24 hours post-wipe,
/// then calculates how many hours after wipe completion that peak occurred.
/// Returns average across all wipes in the time range.
pub async fn get_wipe_to_peak_population(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<f64> {
// For each successful wipe, find the peak player count within 24 hours post-wipe
// and calculate hours from wipe completion to that peak.
let result: Option<(Option<f64>,)> = sqlx::query_as(
"WITH wipe_peaks AS (
SELECT
wh.id as wipe_id,
wh.completed_at,
MAX(ssh.max_players) as peak_players,
(SELECT ssh2.hour
FROM server_stats_hourly ssh2
WHERE ssh2.license_id = wh.license_id
AND ssh2.hour >= wh.completed_at
AND ssh2.hour < wh.completed_at + INTERVAL '24 hours'
AND ssh2.max_players = MAX(ssh.max_players)
ORDER BY ssh2.hour ASC
LIMIT 1
) as peak_hour
FROM wipe_history wh
LEFT JOIN server_stats_hourly ssh
ON ssh.license_id = wh.license_id
AND ssh.hour >= wh.completed_at
AND ssh.hour < wh.completed_at + INTERVAL '24 hours'
WHERE wh.license_id = $1
AND wh.created_at >= NOW() - ($2 || ' days')::INTERVAL
AND wh.status = 'success'
AND wh.completed_at IS NOT NULL
GROUP BY wh.id, wh.completed_at
)
SELECT AVG(EXTRACT(EPOCH FROM (peak_hour - completed_at)) / 3600.0) as avg_hours_to_peak
FROM wipe_peaks
WHERE peak_hour IS NOT NULL",
)
.bind(license_id)
.bind(days)
.fetch_optional(pool)
.await
.context("Failed to query wipe-to-peak population")?;
Ok(result.and_then(|r| r.0).unwrap_or(0.0))
}
/// Get population curve: average player count on day 1, day 2, day 3 post-wipe.
/// Aggregates all successful wipes in the time range.
pub async fn get_population_curve_by_cycle(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<PopulationCurve> {
// For each successful wipe, calculate avg players on day 1 (0-24h), day 2 (24-48h), day 3 (48-72h)
let result: Vec<(i32, f64)> = sqlx::query_as(
"WITH wipe_days AS (
SELECT
wh.id as wipe_id,
wh.completed_at,
CASE
WHEN ssh.hour >= wh.completed_at AND ssh.hour < wh.completed_at + INTERVAL '24 hours' THEN 1
WHEN ssh.hour >= wh.completed_at + INTERVAL '24 hours' AND ssh.hour < wh.completed_at + INTERVAL '48 hours' THEN 2
WHEN ssh.hour >= wh.completed_at + INTERVAL '48 hours' AND ssh.hour < wh.completed_at + INTERVAL '72 hours' THEN 3
END as day_num,
ssh.avg_players
FROM wipe_history wh
LEFT JOIN server_stats_hourly ssh
ON ssh.license_id = wh.license_id
AND ssh.hour >= wh.completed_at
AND ssh.hour < wh.completed_at + INTERVAL '72 hours'
WHERE wh.license_id = $1
AND wh.created_at >= NOW() - ($2 || ' days')::INTERVAL
AND wh.status = 'success'
AND wh.completed_at IS NOT NULL
)
SELECT day_num, AVG(avg_players) as avg_players_per_day
FROM wipe_days
WHERE day_num IS NOT NULL
GROUP BY day_num
ORDER BY day_num",
)
.bind(license_id)
.bind(days)
.fetch_all(pool)
.await
.context("Failed to query population curve")?;
let mut curve = PopulationCurve {
day_1_avg: 0.0,
day_2_avg: 0.0,
day_3_avg: 0.0,
};
for (day_num, avg_players) in result {
match day_num {
1 => curve.day_1_avg = avg_players,
2 => curve.day_2_avg = avg_players,
3 => curve.day_3_avg = avg_players,
_ => {}
}
}
Ok(curve)
}
/// Get optimal wipe timing: best day of week and hour based on historical peak populations.
/// Analyzes successful wipes and their subsequent player peaks.
pub async fn get_optimal_wipe_timing(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<OptimalWipeTiming> {
// Find the day of week and hour with highest average peak population within 24h post-wipe
let result: Option<(String, f64, f64)> = sqlx::query_as(
"WITH wipe_performance AS (
SELECT
wh.id as wipe_id,
wh.completed_at,
EXTRACT(DOW FROM wh.completed_at) as day_of_week,
EXTRACT(HOUR FROM wh.completed_at) as hour_of_day,
MAX(ssh.max_players) as peak_players
FROM wipe_history wh
LEFT JOIN server_stats_hourly ssh
ON ssh.license_id = wh.license_id
AND ssh.hour >= wh.completed_at
AND ssh.hour < wh.completed_at + INTERVAL '24 hours'
WHERE wh.license_id = $1
AND wh.created_at >= NOW() - ($2 || ' days')::INTERVAL
AND wh.status = 'success'
AND wh.completed_at IS NOT NULL
GROUP BY wh.id, wh.completed_at
),
best_timing AS (
SELECT
day_of_week,
hour_of_day,
AVG(peak_players) as avg_peak_players
FROM wipe_performance
GROUP BY day_of_week, hour_of_day
ORDER BY avg_peak_players DESC
LIMIT 1
)
SELECT
CASE day_of_week::INTEGER
WHEN 0 THEN 'Sunday'
WHEN 1 THEN 'Monday'
WHEN 2 THEN 'Tuesday'
WHEN 3 THEN 'Wednesday'
WHEN 4 THEN 'Thursday'
WHEN 5 THEN 'Friday'
WHEN 6 THEN 'Saturday'
END as day_name,
hour_of_day,
avg_peak_players
FROM best_timing",
)
.bind(license_id)
.bind(days)
.fetch_optional(pool)
.await
.context("Failed to query optimal wipe timing")?;
let (day_name, hour_of_day, _) = result.unwrap_or(("Thursday".to_string(), 18.0, 0.0));
Ok(OptimalWipeTiming {
optimal_wipe_day: day_name,
optimal_wipe_hour: hour_of_day as i32,
})
}
/// Get detailed wipe analytics entries (for charting).
/// Returns individual wipe records with duration, peak population, hours to peak.
pub async fn get_wipe_analytics_entries(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<Vec<WipeAnalyticsEntry>> {
let rows: Vec<(Uuid, DateTime<Utc>, Option<DateTime<Utc>>, Option<DateTime<Utc>>, String)> = sqlx::query_as(
"SELECT id, created_at, started_at, completed_at, status
FROM wipe_history
WHERE license_id = $1
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
AND status IN ('success', 'failed', 'rolled_back')
ORDER BY created_at ASC",
)
.bind(license_id)
.bind(days)
.fetch_all(pool)
.await
.context("Failed to query wipe analytics entries")?;
let mut entries = Vec::new();
for (wipe_id, created_at, started_at, completed_at, status) in rows {
let success = status == "success";
let duration_seconds = if let (Some(start), Some(end)) = (started_at, completed_at) {
(end - start).num_seconds()
} else {
0
};
// Find peak population and hours to peak for successful wipes
let (peak_population, hours_to_peak) = if success && completed_at.is_some() {
let peak_result: Option<(i32, DateTime<Utc>)> = sqlx::query_as(
"SELECT max_players, hour
FROM server_stats_hourly
WHERE license_id = $1
AND hour >= $2
AND hour < $2 + INTERVAL '24 hours'
ORDER BY max_players DESC, hour ASC
LIMIT 1",
)
.bind(license_id)
.bind(completed_at.unwrap())
.fetch_optional(pool)
.await
.context("Failed to query peak population for wipe")?;
if let Some((peak, peak_hour)) = peak_result {
let hours = (peak_hour - completed_at.unwrap()).num_seconds() as f64 / 3600.0;
(peak, hours)
} else {
(0, 0.0)
}
} else {
(0, 0.0)
};
entries.push(WipeAnalyticsEntry {
date: created_at,
duration_seconds,
peak_population,
hours_to_peak,
success,
});
}
Ok(entries)
}