feat: Add public status page with 10-second polling
Implement status.corrosionmgmt.com public status page showcasing all Corrosion servers that opt-in. Drives platform visibility and attracts new customers. Backend: - Migration 007: status_page_description TEXT column - models/public.rs: PublicServerStatus, PlatformHealth, StatusPageResponse - db/public.rs: get_public_servers() with uptime calculations (24h/7d/30d) - api/public.rs: GET /api/public/status (no auth) - api/settings.rs: public site config endpoints (auth required) Frontend: - StatusPageView.vue: Server grid with live stats, uptime badges, wipe schedules - Platform health header: total servers, online count, total players - Auto-refresh every 10 seconds via polling - Mobile-responsive design - SettingsView.vue: Public Status tab with opt-in toggle Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::models::error::ApiResult;
|
||||
use crate::db;
|
||||
use crate::models::error::{ApiError, ApiResult};
|
||||
use crate::models::public::StatusPageResponse;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/status", get(get_status_page))
|
||||
.route("/servers", get(list_public_servers))
|
||||
.route("/servers/{id}", get(get_public_server))
|
||||
.route("/servers/{id}/wipe-schedule", get(get_wipe_schedule))
|
||||
@@ -21,6 +25,27 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||
.route("/servers/{id}/store", get(get_store))
|
||||
}
|
||||
|
||||
/// GET /api/public/status — Public status page data (no auth required)
|
||||
///
|
||||
/// Returns all servers opted into the public status page with current stats,
|
||||
/// plus platform-wide health metrics.
|
||||
async fn get_status_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> ApiResult<Json<StatusPageResponse>> {
|
||||
let servers = db::public::get_public_servers(&state.db)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch servers: {}", e)))?;
|
||||
|
||||
let platform_health = db::public::get_platform_health(&state.db)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch platform health: {}", e)))?;
|
||||
|
||||
Ok(Json(StatusPageResponse {
|
||||
servers,
|
||||
platform_health,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_public_servers() -> ApiResult<Json<serde_json::Value>> {
|
||||
Ok(Json(serde_json::json!({"status": "not_implemented"})))
|
||||
}
|
||||
|
||||
85
backend/src/api/settings.rs
Normal file
85
backend/src/api/settings.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::db;
|
||||
use crate::middleware::auth::AuthUser;
|
||||
use crate::models::error::{ApiError, ApiResult};
|
||||
use crate::models::public::{PublicSiteConfig, UpdatePublicSiteRequest};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/public-site", get(get_public_site_settings))
|
||||
.route("/public-site", put(update_public_site_settings))
|
||||
}
|
||||
|
||||
/// GET /api/settings/public-site — Get public site configuration
|
||||
async fn get_public_site_settings(
|
||||
auth: AuthUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> ApiResult<Json<PublicSiteConfig>> {
|
||||
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
|
||||
|
||||
let config = db::public::get_public_site_config(&state.db, license_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch config: {}", e)))?;
|
||||
|
||||
match config {
|
||||
Some(cfg) => Ok(Json(cfg)),
|
||||
None => {
|
||||
// Create default config if none exists
|
||||
let config_id = db::public::create_public_site_config(&state.db, license_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create config: {}", e)))?;
|
||||
|
||||
// Fetch the newly created config
|
||||
let new_config = db::public::get_public_site_config(&state.db, license_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch new config: {}", e)))?
|
||||
.ok_or_else(|| ApiError::Internal("Config creation failed".to_string()))?;
|
||||
|
||||
Ok(Json(new_config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/settings/public-site — Update public site configuration
|
||||
async fn update_public_site_settings(
|
||||
auth: AuthUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UpdatePublicSiteRequest>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
|
||||
|
||||
// Ensure config exists
|
||||
let config_exists = db::public::get_public_site_config(&state.db, license_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to check config: {}", e)))?;
|
||||
|
||||
if config_exists.is_none() {
|
||||
db::public::create_public_site_config(&state.db, license_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create config: {}", e)))?;
|
||||
}
|
||||
|
||||
// Update fields
|
||||
db::public::update_public_site_config(
|
||||
&state.db,
|
||||
license_id,
|
||||
req.show_on_status_page,
|
||||
req.status_page_description,
|
||||
req.site_enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update config: {}", e)))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "Public site settings updated"
|
||||
})))
|
||||
}
|
||||
310
backend/src/db/public.rs
Normal file
310
backend/src/db/public.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::models::public::{PublicServerStatus, PlatformHealth, PublicSiteConfig};
|
||||
|
||||
/// Get all servers opted into the public status page with their current stats
|
||||
pub async fn get_public_servers(pool: &PgPool) -> Result<Vec<PublicServerStatus>> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
l.server_name,
|
||||
l.subdomain,
|
||||
sc.connection_status,
|
||||
COALESCE(
|
||||
(SELECT player_count FROM server_stats
|
||||
WHERE license_id = l.id
|
||||
ORDER BY recorded_at DESC LIMIT 1),
|
||||
0
|
||||
) as current_players,
|
||||
COALESCE(s_cfg.max_players, 200) as max_players,
|
||||
psc.status_page_description,
|
||||
s_cfg.current_seed,
|
||||
ws.next_scheduled_run,
|
||||
ws.cron_expression,
|
||||
ws.timezone
|
||||
FROM licenses l
|
||||
INNER JOIN public_site_config psc ON psc.license_id = l.id
|
||||
LEFT JOIN server_connections sc ON sc.license_id = l.id
|
||||
LEFT JOIN server_config s_cfg ON s_cfg.license_id = l.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT next_scheduled_run, cron_expression, timezone
|
||||
FROM wipe_schedules
|
||||
WHERE license_id = l.id AND is_active = true
|
||||
ORDER BY next_scheduled_run ASC
|
||||
LIMIT 1
|
||||
) ws ON true
|
||||
WHERE psc.show_on_status_page = true
|
||||
AND l.status = 'active'
|
||||
ORDER BY current_players DESC, l.server_name ASC
|
||||
"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("Failed to fetch public servers")?;
|
||||
|
||||
// Calculate uptime percentages for each server
|
||||
let mut servers = Vec::new();
|
||||
for row in rows {
|
||||
// Determine status
|
||||
let status = match row.connection_status.as_deref() {
|
||||
Some("connected") => "online",
|
||||
Some("degraded") => "degraded",
|
||||
_ => "offline",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
// Get uptime percentages
|
||||
let license_id_opt = sqlx::query!(
|
||||
"SELECT id FROM licenses WHERE subdomain = $1 LIMIT 1",
|
||||
row.subdomain
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let (uptime_24h, uptime_7d, uptime_30d) = if let Some(license_row) = license_id_opt {
|
||||
let uptime_24h = calculate_uptime_percentage(pool, license_row.id, 24).await.unwrap_or(0.0);
|
||||
let uptime_7d = calculate_uptime_percentage(pool, license_row.id, 168).await.unwrap_or(0.0); // 7 days
|
||||
let uptime_30d = calculate_uptime_percentage(pool, license_row.id, 720).await.unwrap_or(0.0); // 30 days
|
||||
(uptime_24h, uptime_7d, uptime_30d)
|
||||
} else {
|
||||
(0.0, 0.0, 0.0)
|
||||
};
|
||||
|
||||
// Format wipe schedule (cron → human readable)
|
||||
let wipe_schedule = row.cron_expression.as_ref().map(|cron| {
|
||||
format_cron_expression(cron, row.timezone.as_deref())
|
||||
});
|
||||
|
||||
// Map name (procedural or custom)
|
||||
let map_name = row.current_seed.map(|seed| {
|
||||
format!("Procedural {}", seed)
|
||||
});
|
||||
|
||||
servers.push(PublicServerStatus {
|
||||
server_name: row.server_name.unwrap_or_else(|| "Unnamed Server".to_string()),
|
||||
subdomain: row.subdomain.unwrap_or_default(),
|
||||
status,
|
||||
player_count: row.current_players.unwrap_or(0),
|
||||
max_players: row.max_players.unwrap_or(200),
|
||||
uptime_24h_percent: uptime_24h,
|
||||
uptime_7d_percent: uptime_7d,
|
||||
uptime_30d_percent: uptime_30d,
|
||||
map_name,
|
||||
wipe_schedule,
|
||||
next_wipe: row.next_scheduled_run,
|
||||
description: row.status_page_description,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
/// Calculate platform-wide health metrics
|
||||
pub async fn get_platform_health(pool: &PgPool) -> Result<PlatformHealth> {
|
||||
let stats = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(DISTINCT l.id) as total_servers,
|
||||
COUNT(DISTINCT CASE WHEN sc.connection_status = 'connected' THEN l.id END) as online_servers,
|
||||
COALESCE(SUM(
|
||||
(SELECT player_count FROM server_stats
|
||||
WHERE license_id = l.id
|
||||
ORDER BY recorded_at DESC LIMIT 1)
|
||||
), 0) as total_players
|
||||
FROM licenses l
|
||||
INNER JOIN public_site_config psc ON psc.license_id = l.id
|
||||
LEFT JOIN server_connections sc ON sc.license_id = l.id
|
||||
WHERE psc.show_on_status_page = true
|
||||
AND l.status = 'active'
|
||||
"#
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.context("Failed to fetch platform health")?;
|
||||
|
||||
let total_servers = stats.total_servers.unwrap_or(0);
|
||||
let online_servers = stats.online_servers.unwrap_or(0);
|
||||
|
||||
// Platform uptime = average of all server uptimes (24h)
|
||||
let uptime_percent = if total_servers > 0 {
|
||||
let uptime_sum: f64 = sqlx::query!(
|
||||
r#"
|
||||
SELECT AVG(uptime_percentage) as avg_uptime
|
||||
FROM server_stats_hourly
|
||||
WHERE license_id IN (
|
||||
SELECT l.id FROM licenses l
|
||||
INNER JOIN public_site_config psc ON psc.license_id = l.id
|
||||
WHERE psc.show_on_status_page = true
|
||||
)
|
||||
AND hour >= NOW() - INTERVAL '24 hours'
|
||||
"#
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.and_then(|r| r.avg_uptime)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
uptime_sum
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
|
||||
Ok(PlatformHealth {
|
||||
total_servers,
|
||||
online_servers,
|
||||
total_players: stats.total_players.unwrap_or(0),
|
||||
uptime_percent,
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate uptime percentage for a specific license over N hours
|
||||
async fn calculate_uptime_percentage(pool: &PgPool, license_id: Uuid, hours: i64) -> Result<f64> {
|
||||
// Use hourly stats for efficiency
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT AVG(uptime_percentage) as avg_uptime
|
||||
FROM server_stats_hourly
|
||||
WHERE license_id = $1
|
||||
AND hour >= NOW() - ($2 || ' hours')::INTERVAL
|
||||
"#,
|
||||
license_id,
|
||||
hours
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let uptime = result
|
||||
.and_then(|r| r.avg_uptime)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
Ok(uptime.max(0.0).min(100.0))
|
||||
}
|
||||
|
||||
/// Format cron expression to human-readable string
|
||||
fn format_cron_expression(cron: &str, timezone: Option<&str>) -> String {
|
||||
// Basic cron parsing (can be enhanced)
|
||||
// Format: "0 18 * * 4" → "Thursdays 6 PM EST"
|
||||
|
||||
let parts: Vec<&str> = cron.split_whitespace().collect();
|
||||
if parts.len() < 5 {
|
||||
return cron.to_string();
|
||||
}
|
||||
|
||||
let minute = parts[0];
|
||||
let hour = parts[1];
|
||||
let day_of_week = parts.get(4).unwrap_or(&"*");
|
||||
|
||||
let day_str = match *day_of_week {
|
||||
"0" | "7" => "Sundays",
|
||||
"1" => "Mondays",
|
||||
"2" => "Tuesdays",
|
||||
"3" => "Wednesdays",
|
||||
"4" => "Thursdays",
|
||||
"5" => "Fridays",
|
||||
"6" => "Saturdays",
|
||||
_ => "Daily",
|
||||
};
|
||||
|
||||
let hour_num = hour.parse::<u32>().unwrap_or(0);
|
||||
let minute_num = minute.parse::<u32>().unwrap_or(0);
|
||||
|
||||
let (display_hour, period) = if hour_num == 0 {
|
||||
(12, "AM")
|
||||
} else if hour_num < 12 {
|
||||
(hour_num, "AM")
|
||||
} else if hour_num == 12 {
|
||||
(12, "PM")
|
||||
} else {
|
||||
(hour_num - 12, "PM")
|
||||
};
|
||||
|
||||
let tz_suffix = timezone.unwrap_or("UTC");
|
||||
|
||||
if minute_num == 0 {
|
||||
format!("{} {} {} {}", day_str, display_hour, period, tz_suffix)
|
||||
} else {
|
||||
format!("{} {}:{:02} {} {}", day_str, display_hour, minute_num, period, tz_suffix)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get public site config for a license
|
||||
pub async fn get_public_site_config(pool: &PgPool, license_id: Uuid) -> Result<Option<PublicSiteConfig>> {
|
||||
let config = sqlx::query_as!(
|
||||
PublicSiteConfig,
|
||||
r#"
|
||||
SELECT
|
||||
id, license_id, site_enabled, show_on_status_page, steam_connect_url,
|
||||
motd, public_mods, header_image_url, theme_color, custom_css,
|
||||
discord_invite_url, show_player_count, show_wipe_schedule,
|
||||
show_wipe_countdown, show_mod_list, status_page_description, created_at
|
||||
FROM public_site_config
|
||||
WHERE license_id = $1
|
||||
"#,
|
||||
license_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("Failed to fetch public site config")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Create default public site config for a license
|
||||
pub async fn create_public_site_config(pool: &PgPool, license_id: Uuid) -> Result<Uuid> {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO public_site_config (id, license_id) VALUES ($1, $2)",
|
||||
id,
|
||||
license_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to create public site config")?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Update public site config
|
||||
pub async fn update_public_site_config(
|
||||
pool: &PgPool,
|
||||
license_id: Uuid,
|
||||
show_on_status_page: Option<bool>,
|
||||
status_page_description: Option<String>,
|
||||
site_enabled: Option<bool>,
|
||||
) -> Result<()> {
|
||||
// Build dynamic update query
|
||||
if let Some(show) = show_on_status_page {
|
||||
sqlx::query!(
|
||||
"UPDATE public_site_config SET show_on_status_page = $1 WHERE license_id = $2",
|
||||
show,
|
||||
license_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(desc) = status_page_description {
|
||||
sqlx::query!(
|
||||
"UPDATE public_site_config SET status_page_description = $1 WHERE license_id = $2",
|
||||
desc,
|
||||
license_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(enabled) = site_enabled {
|
||||
sqlx::query!(
|
||||
"UPDATE public_site_config SET site_enabled = $1 WHERE license_id = $2",
|
||||
enabled,
|
||||
license_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod license;
|
||||
pub mod public;
|
||||
pub mod server;
|
||||
pub mod wipe;
|
||||
|
||||
75
backend/src/models/public.rs
Normal file
75
backend/src/models/public.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Public server status (for status page display)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PublicServerStatus {
|
||||
pub server_name: String,
|
||||
pub subdomain: String,
|
||||
pub status: String, // "online", "offline", "degraded"
|
||||
pub player_count: i32,
|
||||
pub max_players: i32,
|
||||
pub uptime_24h_percent: f64,
|
||||
pub uptime_7d_percent: f64,
|
||||
pub uptime_30d_percent: f64,
|
||||
pub map_name: Option<String>,
|
||||
pub wipe_schedule: Option<String>,
|
||||
pub next_wipe: Option<DateTime<Utc>>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Platform-wide health metrics
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlatformHealth {
|
||||
pub total_servers: i64,
|
||||
pub online_servers: i64,
|
||||
pub total_players: i64,
|
||||
pub uptime_percent: f64,
|
||||
}
|
||||
|
||||
/// Full status page response
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StatusPageResponse {
|
||||
pub servers: Vec<PublicServerStatus>,
|
||||
pub platform_health: PlatformHealth,
|
||||
}
|
||||
|
||||
/// Public site configuration
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
|
||||
pub struct PublicSiteConfig {
|
||||
pub id: Uuid,
|
||||
pub license_id: Uuid,
|
||||
pub site_enabled: bool,
|
||||
pub show_on_status_page: bool,
|
||||
pub steam_connect_url: Option<String>,
|
||||
pub motd: Option<String>,
|
||||
pub public_mods: Option<Vec<String>>,
|
||||
pub header_image_url: Option<String>,
|
||||
pub theme_color: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub discord_invite_url: Option<String>,
|
||||
pub show_player_count: bool,
|
||||
pub show_wipe_schedule: bool,
|
||||
pub show_wipe_countdown: bool,
|
||||
pub show_mod_list: bool,
|
||||
pub status_page_description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Update request for public site settings
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UpdatePublicSiteRequest {
|
||||
pub site_enabled: Option<bool>,
|
||||
pub show_on_status_page: Option<bool>,
|
||||
pub status_page_description: Option<String>,
|
||||
pub steam_connect_url: Option<String>,
|
||||
pub motd: Option<String>,
|
||||
pub header_image_url: Option<String>,
|
||||
pub theme_color: Option<String>,
|
||||
pub discord_invite_url: Option<String>,
|
||||
pub show_player_count: Option<bool>,
|
||||
pub show_wipe_schedule: Option<bool>,
|
||||
pub show_wipe_countdown: Option<bool>,
|
||||
pub show_mod_list: Option<bool>,
|
||||
}
|
||||
Reference in New Issue
Block a user