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