Add player stats and server leaderboards to Desktop app and polish UI

This commit is contained in:
SCOPEDD committed 2026-09-29 02:23:18 -04:00
1 parent ebd9de1bee
commit 3043f3ab42
12 files changed
+826 -66

No files matched your search

+193 -23
View File
@@ -2,7 +2,7 @@
//! report to these endpoints with a per-server token, and admins manage the
//! servers (and read the stats they collect) from the panel.
use crate::auth::{self, AdminUser, UserRow};
use crate::auth::{self, AdminUser, AuthUser, UserRow};
use crate::error::{AppError, AppResult};
use crate::net;
use crate::state::AppState;
@@ -640,30 +640,30 @@ pub async fn remove(_: AdminUser, State(state): State<AppState>, Path(id): Path<
#[derive(Serialize, sqlx::FromRow)]
pub struct StatRow {
server_id: i64,
uuid: String,
name: String,
playtime_secs: i64,
joins: i64,
deaths: i64,
player_kills: i64,
mob_kills: i64,
blocks_broken: i64,
blocks_placed: i64,
messages: i64,
first_seen: String,
last_seen: String,
pub server_id: i64,
pub uuid: String,
pub name: String,
pub playtime_secs: i64,
pub joins: i64,
pub deaths: i64,
pub player_kills: i64,
pub mob_kills: i64,
pub blocks_broken: i64,
pub blocks_placed: i64,
pub messages: i64,
pub first_seen: String,
pub last_seen: String,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct EventRow {
id: i64,
server_id: i64,
uuid: Option<String>,
name: Option<String>,
kind: String,
detail: Option<String>,
created_at: String,
pub id: i64,
pub server_id: i64,
pub uuid: Option<String>,
pub name: Option<String>,
pub kind: String,
pub detail: Option<String>,
pub created_at: String,
}
#[derive(Serialize, sqlx::FromRow)]
@@ -754,8 +754,178 @@ pub async fn user_activity(_: AdminUser, State(state): State<AppState>, Path(use
pub struct StatRowNamed {
#[sqlx(flatten)]
#[serde(flatten)]
stats: StatRow,
server_name: String,
pub stats: StatRow,
pub server_name: String,
}
#[derive(Deserialize)]
pub struct LeaderboardQuery {
#[serde(default = "default_sort")]
pub sort: String,
#[serde(default = "default_limit")]
pub limit: i64,
}
fn default_limit() -> i64 {
50
}
#[derive(Serialize, sqlx::FromRow)]
pub struct AggregatedStatRow {
pub uuid: String,
pub name: String,
pub playtime_secs: i64,
pub joins: i64,
pub deaths: i64,
pub player_kills: i64,
pub mob_kills: i64,
pub blocks_broken: i64,
pub blocks_placed: i64,
pub messages: i64,
pub first_seen: String,
pub last_seen: String,
}
/// Public list of servers with status and player counts.
pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Value>> {
let servers: Vec<ServerRow> = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?;
let list: Vec<Value> = servers.into_iter().map(|s| {
let is_online = s.is_online();
json!({
"id": s.id,
"name": s.name,
"online": is_online,
"players_online": if is_online { s.online_count } else { 0 },
"players_max": s.max_players,
"software": s.software,
"mc_version": s.mc_version,
"tps": s.tps,
"last_seen": s.last_seen,
})
}).collect();
Ok(Json(json!({ "servers": list })))
}
/// Public server-specific leaderboard.
pub async fn public_server_leaderboard(
State(state): State<AppState>,
Path(id): Path<i64>,
Query(q): Query<LeaderboardQuery>,
) -> AppResult<Json<Value>> {
let server = get_server(&state, id).await?;
let sort = match q.sort.as_str() {
"joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(),
_ => "playtime_secs",
};
let limit = q.limit.clamp(1, 100);
let leaderboard: Vec<StatRow> =
sqlx::query_as(&format!("SELECT * FROM player_stats WHERE server_id = ? ORDER BY {sort} DESC LIMIT {limit}"))
.bind(id)
.fetch_all(&state.db)
.await?;
let (players, playtime): (i64, Option<i64>) =
sqlx::query_as("SELECT COUNT(*), SUM(playtime_secs) FROM player_stats WHERE server_id = ?").bind(id).fetch_one(&state.db).await?;
Ok(Json(json!({
"server": {
"id": server.id,
"name": server.name,
"online": server.is_online(),
"players_online": server.online_count,
"players_max": server.max_players,
},
"leaderboard": leaderboard,
"totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) },
"sort": sort,
})))
}
/// Public global leaderboard aggregated across all servers.
pub async fn global_leaderboard(
State(state): State<AppState>,
Query(q): Query<LeaderboardQuery>,
) -> AppResult<Json<Value>> {
let sort = match q.sort.as_str() {
"joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(),
_ => "playtime_secs",
};
let limit = q.limit.clamp(1, 100);
let leaderboard: Vec<AggregatedStatRow> = sqlx::query_as(&format!(
"SELECT uuid, name,
SUM(playtime_secs) as playtime_secs,
SUM(joins) as joins,
SUM(deaths) as deaths,
SUM(player_kills) as player_kills,
SUM(mob_kills) as mob_kills,
SUM(blocks_broken) as blocks_broken,
SUM(blocks_placed) as blocks_placed,
SUM(messages) as messages,
MIN(first_seen) as first_seen,
MAX(last_seen) as last_seen
FROM player_stats
GROUP BY uuid
ORDER BY {sort} DESC
LIMIT {limit}"
))
.fetch_all(&state.db)
.await?;
let (players, playtime): (i64, Option<i64>) =
sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats")
.fetch_one(&state.db)
.await?;
Ok(Json(json!({
"leaderboard": leaderboard,
"totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) },
"sort": sort,
})))
}
/// Player's personal stats across servers (authenticated endpoint for launcher).
pub async fn account_player_stats(
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<Value>> {
let rows: Vec<StatRowNamed> = sqlx::query_as(
"SELECT s.*, g.name AS server_name FROM player_stats s JOIN game_servers g ON g.id = s.server_id WHERE s.uuid = ? ORDER BY s.last_seen DESC",
)
.bind(&user.uuid)
.fetch_all(&state.db)
.await?;
let total: Option<AggregatedStatRow> = sqlx::query_as(
"SELECT uuid, name,
SUM(playtime_secs) as playtime_secs,
SUM(joins) as joins,
SUM(deaths) as deaths,
SUM(player_kills) as player_kills,
SUM(mob_kills) as mob_kills,
SUM(blocks_broken) as blocks_broken,
SUM(blocks_placed) as blocks_placed,
SUM(messages) as messages,
MIN(first_seen) as first_seen,
MAX(last_seen) as last_seen
FROM player_stats
WHERE uuid = ?
GROUP BY uuid"
)
.bind(&user.uuid)
.fetch_optional(&state.db)
.await?;
let events: Vec<EventRow> =
sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20")
.bind(&user.uuid)
.fetch_all(&state.db)
.await?;
Ok(Json(json!({
"username": user.username,
"uuid": user.uuid,
"total": total,
"servers": rows,
"events": events,
})))
}
/// Live summary for the dashboard.