use axum::{ extract::{Path, Query, State}, response::{Html, IntoResponse}, }; use chrono::{DateTime, Utc}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, sync::Arc}; use tera::Context; use crate::{AppState, TEMPLATES}; #[derive(Debug, Serialize, Deserialize)] pub struct SortParams { pub sort: Option, pub order: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct Report { pub id: u64, pub mac_address: String, pub timestamp: String, pub slimes: HashMap>, pub benchmark: Benchmark, pub client_version: String, pub signature: String, } #[derive(serde::Serialize)] pub struct ReportRow { pub id: u64, pub score: u64, pub single_score: u64, pub multi_score: u64, pub cores: u32, pub ram: String, pub ram_raw: f32, pub os: String, pub hostname: String, pub time_ago: String, pub timestamp: String, pub signature: String, pub is_new: bool, } #[derive(Debug, Serialize, Deserialize)] pub struct Benchmark { pub logical_cores: u32, pub multi_thread: ScoreDetail, pub single_thread: ScoreDetail, } #[derive(Debug, Serialize, Deserialize)] pub struct ScoreDetail { pub score: u64, } fn format_time_ago(timestamp: &str) -> String { let Ok(ts) = DateTime::parse_from_rfc3339(timestamp) else { return "unknown".into(); }; let now = Utc::now(); let diff = now.signed_duration_since(ts.with_timezone(&Utc)); if diff.num_days() > 0 { format!("{}d ago", diff.num_days()) } else if diff.num_hours() > 0 { format!("{}h ago", diff.num_hours()) } else if diff.num_minutes() > 0 { format!("{}min ago", diff.num_minutes()) } else { "just now".into() } } fn is_recent(timestamp: &str) -> bool { DateTime::parse_from_rfc3339(timestamp) .map(|ts| { let now = Utc::now(); let diff = now.signed_duration_since(ts.with_timezone(&Utc)); diff.num_hours() < 24 && diff.num_seconds() >= 0 }) .unwrap_or(false) } fn parse_total_ram(ram_str: &str) -> String { let total_part = ram_str.split('/').last().unwrap_or("").trim(); let digits: String = total_part.chars().filter(|c| c.is_ascii_digit()).collect(); if let Ok(mb) = digits.parse::() { format!("{}GB", (mb / 1024.0).round()) } else { "Unknown".into() } } fn parse_total_ram_raw(ram_str: &str) -> f32 { let total_part = ram_str.split('/').last().unwrap_or("").trim(); let digits: String = total_part.chars().filter(|c| c.is_ascii_digit()).collect(); digits.parse::().unwrap_or(0.0) / 1024.0 } pub async fn home_handler( State(state): State>, Query(params): Query, ) -> Result { let response = reqwest::get("https://alatreon.org/slimes?limit=1000") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let raw_reports = response.json::>().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("JSON Error: {}", e), ) })?; let mut reports: Vec = raw_reports .into_iter() .map(|s| ReportRow { id: s.id, score: (s.benchmark.multi_thread.score + s.benchmark.single_thread.score) / 2, single_score: s.benchmark.single_thread.score, multi_score: s.benchmark.multi_thread.score, cores: s.benchmark.logical_cores, ram: s .slimes .get("RAM") .and_then(|v| v.first()) .map(|r| parse_total_ram(r)) .unwrap_or_else(|| "-".into()), ram_raw: s .slimes .get("RAM") .and_then(|v| v.first()) .map(|r| parse_total_ram_raw(r)) .unwrap_or_else(|| 0.0), os: s .slimes .get("OS") .and_then(|v| v.first()) .cloned() .unwrap_or_else(|| "Unknown".into()), hostname: s .slimes .get("Hostname") .and_then(|v| v.first()) .cloned() .unwrap_or_else(|| "-".into()), signature: s.signature, timestamp: s.timestamp.clone(), time_ago: format_time_ago(&s.timestamp), is_new: is_recent(&s.timestamp), }) .collect(); let sort_field = params.sort.unwrap_or_else(|| "score".to_string()); let order = params.order.unwrap_or_else(|| "desc".to_string()); reports.sort_by(|a, b| { let cmp = match sort_field.as_str() { "single" => a.single_score.cmp(&b.single_score), "multi" => a.multi_score.cmp(&b.multi_score), "ram" => a .ram_raw .partial_cmp(&b.ram_raw) .unwrap_or(std::cmp::Ordering::Equal), "threads" => a.cores.cmp(&b.cores), "time" => a.timestamp.cmp(&b.timestamp), _ => a.score.cmp(&b.score), }; if order == "asc" { cmp } else { cmp.reverse() } }); let mut context = Context::new(); context.insert("reports", &reports); context.insert("title", &state.app_name); context.insert("current_sort", &sort_field); context.insert("current_order", &order); context.insert("current_page", "leaderboard"); context.insert("prefix", ""); match TEMPLATES.render("index.html", &context) { Ok(html) => Ok(Html(html)), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), } } pub async fn report_details_handler( State(state): State>, Path(id): Path, ) -> Result { let url = format!("https://alatreon.org/slimes/{}", id); let report = reqwest::get(url) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .json::() .await .map_err(|e| (StatusCode::NOT_FOUND, format!("Report not found: {}", e)))?; let mut context = Context::new(); context.insert("report", &report); context.insert("title", &format!("Report #{} | {}", id, state.app_name)); context.insert("time_ago", &format_time_ago(&report.timestamp)); context.insert( "score", &((report.benchmark.multi_thread.score + report.benchmark.single_thread.score) / 2), ); context.insert("current_page", "details"); context.insert("prefix", "../"); match TEMPLATES.render("details.html", &context) { Ok(html) => Ok(Html(html)), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), } } async fn get_processed_reports() -> Result, (StatusCode, String)> { let response = reqwest::get("https://alatreon.org/slimes?limit=10000") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let raw_reports = response.json::>().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("JSON Error: {}", e), ) })?; Ok(raw_reports .into_iter() .map(|s| { let ram_raw = s .slimes .get("RAM") .and_then(|v| v.first()) .map(|r| parse_total_ram_raw(r)) .unwrap_or(0.0); ReportRow { id: s.id, score: (s.benchmark.multi_thread.score + s.benchmark.single_thread.score) / 2, single_score: s.benchmark.single_thread.score, multi_score: s.benchmark.multi_thread.score, cores: s.benchmark.logical_cores, ram: s .slimes .get("RAM") .and_then(|v| v.first()) .map(|r| parse_total_ram(r)) .unwrap_or_else(|| "-".into()), ram_raw, os: s .slimes .get("OS") .and_then(|v| v.first()) .cloned() .unwrap_or_else(|| "Unknown".into()), hostname: s .slimes .get("Hostname") .and_then(|v| v.first()) .cloned() .unwrap_or_else(|| "-".into()), signature: s.signature, timestamp: s.timestamp.clone(), time_ago: format_time_ago(&s.timestamp), is_new: is_recent(&s.timestamp), } }) .collect()) } #[derive(Serialize)] pub struct AnalyticsData { pub os_labels: Vec, pub os_scores: Vec, pub os_max_labels: Vec, pub os_max_scores: Vec, pub os_min_labels: Vec, pub os_min_scores: Vec, pub os_count_labels: Vec, pub os_count_values: Vec, pub dist_labels: Vec, pub dist_counts: Vec, pub avg_score_per_gb: f32, pub avg_score_per_thread: f32, pub total_reports: usize, } pub async fn analytics_handler( State(state): State>, ) -> Result { let reports = get_processed_reports().await?; if reports.is_empty() { return Err((StatusCode::NOT_FOUND, "No data available".into())); } // Average score per OS let mut os_map: HashMap = HashMap::new(); for r in &reports { let entry = os_map.entry(r.os.clone()).or_insert((0, 0)); entry.0 += r.score; entry.1 += 1; } let mut os_stats: Vec<(String, u64)> = os_map .clone() .into_iter() .map(|(os, (sum, count))| (os, sum / count as u64)) .collect(); os_stats.sort_by(|a, b| b.1.cmp(&a.1)); // Highest score per OS let mut os_max_map: HashMap = HashMap::new(); for r in &reports { let entry = os_max_map.entry(r.os.clone()).or_insert(0); if r.score > *entry { *entry = r.score; } } let mut os_max_stats: Vec<(String, u64)> = os_max_map.into_iter().collect(); os_max_stats.sort_by(|a, b| b.1.cmp(&a.1)); // Lowest score per OS let mut os_min_map: HashMap = HashMap::new(); for r in &reports { os_min_map .entry(r.os.clone()) .and_modify(|e| { if r.score < *e { *e = r.score; } }) .or_insert(r.score); } let mut os_min_stats: Vec<(String, u64)> = os_min_map.into_iter().collect(); os_min_stats.sort_by(|a, b| b.1.cmp(&a.1)); // Count of each OS let mut os_counts: Vec<(String, u32)> = os_map .iter() .map(|(os, (_, count))| (os.clone(), *count)) .collect(); os_counts.sort_by(|a, b| b.1.cmp(&a.1)); // Score distribution let min_score = reports.iter().map(|r| r.score).min().unwrap_or(0); let max_score = reports.iter().map(|r| r.score).max().unwrap_or(0); let range = (max_score - min_score).max(1); let bin_width = range as f32 / 10.0; let mut dist_counts = vec![0u32; 10]; let mut dist_labels = vec![String::new(); 10]; for i in 0..10 { let start = min_score + (i as f32 * bin_width) as u64; let end = min_score + ((i + 1) as f32 * bin_width) as u64; dist_labels[i] = format!("{}-{}", start, end); } for r in &reports { let mut bin_idx = ((r.score - min_score) as f32 / bin_width).floor() as usize; if bin_idx >= 10 { bin_idx = 9; } dist_counts[bin_idx] += 1; } // Ratios let total_reports = reports.len(); let avg_score_per_gb = reports .iter() .filter(|r| r.ram_raw > 0.0) .map(|r| r.score as f32 / r.ram_raw) .sum::() / total_reports as f32; let avg_score_per_thread = reports .iter() .filter(|r| r.cores > 0) .map(|r| r.score as f32 / r.cores as f32) .sum::() / total_reports as f32; let data = AnalyticsData { os_labels: os_stats.iter().map(|x| x.0.clone()).collect(), os_scores: os_stats.iter().map(|x| x.1).collect(), os_max_labels: os_max_stats.iter().map(|x| x.0.clone()).collect(), os_max_scores: os_max_stats.iter().map(|x| x.1).collect(), os_min_labels: os_min_stats.iter().map(|x| x.0.clone()).collect(), os_min_scores: os_min_stats.iter().map(|x| x.1).collect(), os_count_labels: os_counts.iter().map(|x| x.0.clone()).collect(), os_count_values: os_counts.iter().map(|x| x.1).collect(), dist_labels, dist_counts, avg_score_per_gb, avg_score_per_thread, total_reports, }; let json_data = serde_json::to_string(&data) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut context = Context::new(); context.insert("title", &format!("Analytics | {}", state.app_name)); context.insert("json_data", &json_data); context.insert("data", &data); context.insert("current_page", "analytics"); context.insert("prefix", ""); match TEMPLATES.render("analytics.html", &context) { Ok(html) => Ok(Html(html)), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), } }