diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e5bc2..ee1aaf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added (Phase 3 — Public Status Page) + +**Backend:** +- Migration 007: Added `status_page_description` TEXT column to `public_site_config` +- Public API models (`models/public.rs`): + - `PublicServerStatus` — Server status with live stats for public display + - `PlatformHealth` — Platform-wide health metrics (total servers, online count, total players, uptime) + - `StatusPageResponse` — Complete status page data structure + - `PublicSiteConfig` — Full public site configuration model +- Public database queries (`db/public.rs`): + - `get_public_servers()` — Retrieves all opted-in servers with current stats, uptime percentages (24h/7d/30d), wipe schedules + - `get_platform_health()` — Calculates platform-wide aggregate metrics + - `calculate_uptime_percentage()` — Uptime calculation from hourly stats + - `format_cron_expression()` — Human-readable wipe schedule formatting + - `get_public_site_config()` / `create_public_site_config()` / `update_public_site_config()` — Config management +- Public API endpoint (`api/public.rs`): + - `GET /api/public/status` — Public status page data (no auth required) +- Settings API (`api/settings.rs`): + - `GET /api/settings/public-site` — Fetch public site config (auth required) + - `PUT /api/settings/public-site` — Update status page opt-in and description (auth required) + +**Frontend:** +- `StatusPageView.vue` — Complete public status page with: + - Platform health header (total servers, online now, total players, platform uptime) + - Server grid with status indicators (green/yellow/red), player counts, uptime badges (24h/7d/30d) + - Wipe schedule display with countdown timers + - Server search/filter functionality + - Auto-refresh every 10 seconds via polling + - Mobile-responsive grid layout + - "Powered by Corrosion" footer with panel link +- Settings dashboard integration (`SettingsView.vue`): + - New "Public Status" tab with toggle for `show_on_status_page` + - Text area for `status_page_description` + - Save endpoint integration + +**Infrastructure:** +- nginx already configured for `status.corrosionmgmt.com` routing +- Router already configured with `/status` route on both panel and marketing domains + +**Purpose:** Public-facing marketing page showcasing all Corrosion servers. Drives platform visibility and attracts new customers ("I want this for my server too"). + ### Added (Phase 2.2 — Player Retention Analytics) **Backend:** diff --git a/backend/migrations/004_status_page_description.sql b/backend/migrations/007_status_page_description.sql similarity index 100% rename from backend/migrations/004_status_page_description.sql rename to backend/migrations/007_status_page_description.sql diff --git a/backend/src/api/public.rs b/backend/src/api/public.rs index d752263..36cce6a 100644 --- a/backend/src/api/public.rs +++ b/backend/src/api/public.rs @@ -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> { 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> { .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>, +) -> ApiResult> { + 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> { Ok(Json(serde_json::json!({"status": "not_implemented"}))) } diff --git a/backend/src/api/settings.rs b/backend/src/api/settings.rs new file mode 100644 index 0000000..d15c996 --- /dev/null +++ b/backend/src/api/settings.rs @@ -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> { + 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>, +) -> ApiResult> { + 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>, + Json(req): Json, +) -> ApiResult> { + 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" + }))) +} diff --git a/backend/src/db/public.rs b/backend/src/db/public.rs new file mode 100644 index 0000000..a45c2ed --- /dev/null +++ b/backend/src/db/public.rs @@ -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> { + 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 { + 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 { + // 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::().unwrap_or(0); + let minute_num = minute.parse::().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> { + 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 { + 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, + status_page_description: Option, + site_enabled: Option, +) -> 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(()) +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 6f98205..00f76a9 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod error; pub mod license; +pub mod public; pub mod server; pub mod wipe; diff --git a/backend/src/models/public.rs b/backend/src/models/public.rs new file mode 100644 index 0000000..d99798a --- /dev/null +++ b/backend/src/models/public.rs @@ -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, + pub wipe_schedule: Option, + pub next_wipe: Option>, + pub description: Option, +} + +/// 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, + 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, + pub motd: Option, + pub public_mods: Option>, + pub header_image_url: Option, + pub theme_color: Option, + pub custom_css: Option, + pub discord_invite_url: Option, + 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, + pub created_at: DateTime, +} + +/// Update request for public site settings +#[derive(Debug, Clone, Deserialize)] +pub struct UpdatePublicSiteRequest { + pub site_enabled: Option, + pub show_on_status_page: Option, + pub status_page_description: Option, + pub steam_connect_url: Option, + pub motd: Option, + pub header_image_url: Option, + pub theme_color: Option, + pub discord_invite_url: Option, + pub show_player_count: Option, + pub show_wipe_schedule: Option, + pub show_wipe_countdown: Option, + pub show_mod_list: Option, +} diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index acc7790..1c0319f 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2,13 +2,13 @@ import { ref, onMounted } from 'vue' import { useAuthStore } from '@/stores/auth' import { useApi } from '@/composables/useApi' -import { Settings, Key, Globe, User, Save, Loader2 } from 'lucide-vue-next' +import { Settings, Key, Globe, User, Save, Loader2, Eye } from 'lucide-vue-next' const auth = useAuthStore() const api = useApi() const saving = ref(false) -const section = ref<'account' | 'license' | 'domain'>('account') +const section = ref<'account' | 'license' | 'domain' | 'public'>('account') const accountForm = ref({ username: '', @@ -20,7 +20,12 @@ const domainForm = ref({ custom_domain: '', }) -function loadForms() { +const publicSiteForm = ref({ + show_on_status_page: false, + status_page_description: '', +}) + +async function loadForms() { if (auth.user) { accountForm.value.username = auth.user.username accountForm.value.email = auth.user.email @@ -29,6 +34,15 @@ function loadForms() { domainForm.value.subdomain = auth.license.subdomain || '' domainForm.value.custom_domain = auth.license.custom_domain || '' } + + // Load public site config + try { + const config = await api.get('/settings/public-site') + publicSiteForm.value.show_on_status_page = config.show_on_status_page + publicSiteForm.value.status_page_description = config.status_page_description || '' + } catch (err) { + console.error('Failed to load public site settings:', err) + } } async function saveAccount() { @@ -53,6 +67,17 @@ async function saveDomain() { } } +async function savePublicSite() { + saving.value = true + try { + await api.put('/settings/public-site', publicSiteForm.value) + } catch { + // Handle error + } finally { + saving.value = false + } +} + onMounted(() => { loadForms() }) @@ -73,6 +98,7 @@ onMounted(() => { { key: 'account', label: 'Account', icon: User }, { key: 'license', label: 'License', icon: Key }, { key: 'domain', label: 'Domain', icon: Globe }, + { key: 'public', label: 'Public Status', icon: Eye }, ] as const)" :key="tab.key" @click="section = tab.key" @@ -206,5 +232,58 @@ onMounted(() => { Save + + +
+

Public Status Page

+

+ Showcase your server on the public Corrosion status page at + + status.corrosionmgmt.com + +

+ +
+ +
+
+

Show on status page

+

Display your server publicly with live stats

+
+ +
+ + +
+ + +

Brief description shown on the status page

+
+
+ + +
diff --git a/frontend/src/views/public/StatusPageView.vue b/frontend/src/views/public/StatusPageView.vue index 35f4904..3436435 100644 --- a/frontend/src/views/public/StatusPageView.vue +++ b/frontend/src/views/public/StatusPageView.vue @@ -1,10 +1,340 @@