Add player stats and server leaderboards to Desktop app and polish UI
This commit is contained in:
1 parent
ebd9de1bee
commit
3043f3ab42
12 files changed
+826
-66
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user