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:
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user