From 33eb3e4f3fb42814c1cabab3945b1a3dd369438f Mon Sep 17 00:00:00 2001 From: eiiko6 Date: Mon, 4 May 2026 20:26:31 +0200 Subject: [PATCH] added cache to prevent the server from blocking itself if too many requests --- src/handlers.rs | 281 ++++++++++++++++++++------------------- src/main.rs | 17 ++- templates/analytics.html | 10 +- 3 files changed, 162 insertions(+), 146 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index a0cc952..bc2df83 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -5,7 +5,7 @@ use axum::{ use chrono::{DateTime, Utc}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Instant}; use tera::Context; use crate::{AppState, TEMPLATES}; @@ -16,7 +16,7 @@ pub struct SortParams { pub order: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Report { pub id: u64, pub mac_address: String, @@ -44,14 +44,14 @@ pub struct ReportRow { pub is_new: bool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Benchmark { pub logical_cores: u32, pub multi_thread: ScoreDetail, pub single_thread: ScoreDetail, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct ScoreDetail { pub score: u64, } @@ -100,134 +100,9 @@ fn parse_total_ram_raw(ram_str: &str) -> f32 { 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 +/// Map raw Report to ReportRow for UI +fn process_reports(raw_reports: Vec) -> Vec { + raw_reports .into_iter() .map(|s| { let ram_raw = s @@ -267,7 +142,137 @@ async fn get_processed_reports() -> Result, (StatusCode, String)> is_new: is_recent(&s.timestamp), } }) - .collect()) + .collect() +} + +/// Retrieves reports from cache if fresh, otherwise fetches from API +async fn get_cached_reports(state: &AppState) -> Result, (StatusCode, String)> { + { + let cache_read = state.reports_cache.read().await; + if let Some(cache) = &*cache_read { + if cache.updated_at.elapsed() < state.cache_duration { + return Ok(cache.data.clone()); + } + } + } + + let mut cache_write = state.reports_cache.write().await; + + // Double-check? + if let Some(cache) = &*cache_write { + if cache.updated_at.elapsed() < state.cache_duration { + return Ok(cache.data.clone()); + } + } + + tracing::debug!("Refreshing reports cache from API..."); + let response = reqwest::get("https://alatreon.org/slimes?limit=10000") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let fresh_reports = response.json::>().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("JSON Error: {}", e), + ) + })?; + + // NOTE: to be clear we ARE storing all the reports in RAM + *cache_write = Some(crate::Cache { + data: fresh_reports.clone(), + updated_at: Instant::now(), + }); + + Ok(fresh_reports) +} + +pub async fn home_handler( + State(state): State>, + Query(params): Query, +) -> Result { + let raw_reports = get_cached_reports(&state).await?; + let mut reports = process_reports(raw_reports); + + 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 { + // Check if the report exists in our current cache to avoid API call + { + let cache = state.reports_cache.read().await; + if let Some(c) = &*cache { + if let Some(report) = c.data.iter().find(|r| r.id == id) { + return render_details(report.clone(), &state).await; + } + } + } + + // Fallback: fetch specifically if not in list + 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)))?; + + render_details(report, &state).await +} + +async fn render_details( + report: Report, + state: &AppState, +) -> Result, (StatusCode, String)> { + let mut context = Context::new(); + context.insert("report", &report); + context.insert( + "title", + &format!("Report #{} | {}", 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())), + } } #[derive(Serialize)] @@ -290,7 +295,9 @@ pub struct AnalyticsData { pub async fn analytics_handler( State(state): State>, ) -> Result { - let reports = get_processed_reports().await?; + let raw_reports = get_cached_reports(&state).await?; + let reports = process_reports(raw_reports); + if reports.is_empty() { return Err((StatusCode::NOT_FOUND, "No data available".into())); } @@ -332,7 +339,6 @@ pub async fn analytics_handler( }) .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)); @@ -343,10 +349,9 @@ pub async fn analytics_handler( .collect(); os_counts.sort_by(|a, b| b.1.cmp(&a.1)); - // Score distribution + // 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; @@ -367,7 +372,7 @@ pub async fn analytics_handler( dist_counts[bin_idx] += 1; } - // Ratios + // Efficiency Ratios let total_reports = reports.len(); let avg_score_per_gb = reports .iter() diff --git a/src/main.rs b/src/main.rs index 53a0637..b153369 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,17 @@ use axum::{Router, routing::get}; use clap::Parser; use lazy_static::lazy_static; -use std::sync::Arc; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use tera::Tera; +use tokio::sync::RwLock; mod handlers; use handlers::home_handler; -use crate::handlers::{analytics_handler, report_details_handler}; +use crate::handlers::{Report, analytics_handler, report_details_handler}; lazy_static! { pub static ref TEMPLATES: Tera = { @@ -43,8 +47,15 @@ pub struct Cli { verbose: bool, } +pub struct Cache { + pub data: T, + pub updated_at: Instant, +} + struct AppState { app_name: String, + pub reports_cache: RwLock>>>, + pub cache_duration: Duration, } #[tokio::main] @@ -57,6 +68,8 @@ async fn main() -> anyhow::Result<()> { let shared_state = Arc::new(AppState { app_name: "Slimes Leaderboards".to_string(), + reports_cache: RwLock::new(None), + cache_duration: Duration::from_secs(30), }); let app = Router::new() diff --git a/templates/analytics.html b/templates/analytics.html index 7dcacf3..4934534 100644 --- a/templates/analytics.html +++ b/templates/analytics.html @@ -232,7 +232,7 @@ position: absolute; top: 1rem; right: 1rem; - background: var(--primary-soft); + background: var(--bg-main); border: 1px solid var(--primary-border); color: var(--primary-color); padding: 0.4rem 0.7rem; @@ -245,8 +245,7 @@ } .expand-btn:hover { - background: var(--primary-color); - color: white; + background: var(--primary-soft); } .modal { @@ -282,7 +281,7 @@ } .close-modal { - background: var(--primary-soft); + background: var(--bg-main); border: 1px solid var(--primary-border); color: var(--primary-color); width: 32px; @@ -296,8 +295,7 @@ } .close-modal:hover { - background: var(--primary-color); - color: white; + background: var(--primary-soft); } .modal-body {