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:
Vantz Stockwell
2026-02-15 14:24:32 -05:00
parent 1f5516bbec
commit dfa605f44f
9 changed files with 954 additions and 8 deletions

View File

@@ -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"})))
}

View 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
View 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(())
}

View File

@@ -1,5 +1,6 @@
pub mod auth;
pub mod error;
pub mod license;
pub mod public;
pub mod server;
pub mod wipe;

View 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>,
}