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

@@ -4,6 +4,47 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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) ### Added (Phase 2.2 — Player Retention Analytics)
**Backend:** **Backend:**

View File

@@ -1,15 +1,19 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
extract::State,
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use crate::models::error::ApiResult; use crate::db;
use crate::models::error::{ApiError, ApiResult};
use crate::models::public::StatusPageResponse;
use crate::AppState; use crate::AppState;
pub fn router() -> Router<Arc<AppState>> { pub fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/status", get(get_status_page))
.route("/servers", get(list_public_servers)) .route("/servers", get(list_public_servers))
.route("/servers/{id}", get(get_public_server)) .route("/servers/{id}", get(get_public_server))
.route("/servers/{id}/wipe-schedule", get(get_wipe_schedule)) .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)) .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>> { async fn list_public_servers() -> ApiResult<Json<serde_json::Value>> {
Ok(Json(serde_json::json!({"status": "not_implemented"}))) 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 auth;
pub mod error; pub mod error;
pub mod license; pub mod license;
pub mod public;
pub mod server; pub mod server;
pub mod wipe; 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>,
}

View File

@@ -2,13 +2,13 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useApi } from '@/composables/useApi' 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 auth = useAuthStore()
const api = useApi() const api = useApi()
const saving = ref(false) const saving = ref(false)
const section = ref<'account' | 'license' | 'domain'>('account') const section = ref<'account' | 'license' | 'domain' | 'public'>('account')
const accountForm = ref({ const accountForm = ref({
username: '', username: '',
@@ -20,7 +20,12 @@ const domainForm = ref({
custom_domain: '', custom_domain: '',
}) })
function loadForms() { const publicSiteForm = ref({
show_on_status_page: false,
status_page_description: '',
})
async function loadForms() {
if (auth.user) { if (auth.user) {
accountForm.value.username = auth.user.username accountForm.value.username = auth.user.username
accountForm.value.email = auth.user.email accountForm.value.email = auth.user.email
@@ -29,6 +34,15 @@ function loadForms() {
domainForm.value.subdomain = auth.license.subdomain || '' domainForm.value.subdomain = auth.license.subdomain || ''
domainForm.value.custom_domain = auth.license.custom_domain || '' domainForm.value.custom_domain = auth.license.custom_domain || ''
} }
// Load public site config
try {
const config = await api.get<any>('/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() { 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(() => { onMounted(() => {
loadForms() loadForms()
}) })
@@ -73,6 +98,7 @@ onMounted(() => {
{ key: 'account', label: 'Account', icon: User }, { key: 'account', label: 'Account', icon: User },
{ key: 'license', label: 'License', icon: Key }, { key: 'license', label: 'License', icon: Key },
{ key: 'domain', label: 'Domain', icon: Globe }, { key: 'domain', label: 'Domain', icon: Globe },
{ key: 'public', label: 'Public Status', icon: Eye },
] as const)" ] as const)"
:key="tab.key" :key="tab.key"
@click="section = tab.key" @click="section = tab.key"
@@ -206,5 +232,58 @@ onMounted(() => {
Save Save
</button> </button>
</div> </div>
<!-- Public Status Page -->
<div v-if="section === 'public'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Public Status Page</h2>
<p class="text-xs text-neutral-500">
Showcase your server on the public Corrosion status page at
<a href="https://status.corrosionmgmt.com" target="_blank" class="text-oxide-400 hover:text-oxide-300">
status.corrosionmgmt.com
</a>
</p>
<div class="space-y-4">
<!-- Toggle -->
<div class="flex items-center justify-between p-4 bg-neutral-800 rounded-lg border border-neutral-700">
<div>
<p class="text-sm font-medium text-neutral-200">Show on status page</p>
<p class="text-xs text-neutral-500 mt-1">Display your server publicly with live stats</p>
</div>
<button
@click="publicSiteForm.show_on_status_page = !publicSiteForm.show_on_status_page"
:class="publicSiteForm.show_on_status_page ? 'bg-oxide-600' : 'bg-neutral-700'"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
>
<span
:class="publicSiteForm.show_on_status_page ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
></span>
</button>
</div>
<!-- Description -->
<div>
<label class="block text-xs text-neutral-500 mb-1">Description (optional)</label>
<textarea
v-model="publicSiteForm.status_page_description"
placeholder="Friendly 10x modded server with custom plugins..."
rows="3"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
></textarea>
<p class="text-xs text-neutral-600 mt-1">Brief description shown on the status page</p>
</div>
</div>
<button
@click="savePublicSite"
:disabled="saving"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
<Save v-else class="w-4 h-4" />
Save
</button>
</div>
</div> </div>
</template> </template>

View File

@@ -1,10 +1,340 @@
<script setup lang="ts"> <script setup lang="ts">
// TODO: Implement public server status page with uptime and metrics import { ref, onMounted, onUnmounted, computed } from 'vue'
import { Server, Users, Activity, TrendingUp, Search } from 'lucide-vue-next'
import { useApi } from '@/composables/useApi'
interface ServerStatus {
server_name: string
subdomain: string
status: 'online' | 'offline' | 'degraded'
player_count: number
max_players: number
uptime_24h_percent: number
uptime_7d_percent: number
uptime_30d_percent: number
map_name: string | null
wipe_schedule: string | null
next_wipe: string | null
description: string | null
}
interface PlatformHealth {
total_servers: number
online_servers: number
total_players: number
uptime_percent: number
}
interface StatusResponse {
servers: ServerStatus[]
platform_health: PlatformHealth
}
const api = useApi()
const servers = ref<ServerStatus[]>([])
const platformHealth = ref<PlatformHealth>({
total_servers: 0,
online_servers: 0,
total_players: 0,
uptime_percent: 0,
})
const searchQuery = ref('')
const loading = ref(true)
const error = ref<string | null>(null)
let refreshInterval: number | null = null
// Filtered servers based on search
const filteredServers = computed(() => {
if (!searchQuery.value) return servers.value
const query = searchQuery.value.toLowerCase()
return servers.value.filter(
(s) =>
s.server_name.toLowerCase().includes(query) ||
s.subdomain.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query)
)
})
async function fetchStatus() {
try {
const response = await api.get<StatusResponse>('/public/status')
servers.value = response.servers
platformHealth.value = response.platform_health
error.value = null
} catch (err) {
console.error('Failed to fetch status:', err)
error.value = 'Failed to load server status'
} finally {
loading.value = false
}
}
function getStatusColor(status: string) {
switch (status) {
case 'online':
return 'bg-green-500'
case 'degraded':
return 'bg-yellow-500'
default:
return 'bg-red-500'
}
}
function getStatusText(status: string) {
switch (status) {
case 'online':
return 'Online'
case 'degraded':
return 'Degraded'
default:
return 'Offline'
}
}
function getUptimeBadgeColor(uptime: number) {
if (uptime >= 99) return 'bg-green-500/10 text-green-400'
if (uptime >= 95) return 'bg-yellow-500/10 text-yellow-400'
return 'bg-red-500/10 text-red-400'
}
function formatTimeUntil(isoDate: string | null): string {
if (!isoDate) return 'Not scheduled'
const now = new Date()
const target = new Date(isoDate)
const diff = target.getTime() - now.getTime()
if (diff < 0) return 'Overdue'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
onMounted(() => {
fetchStatus()
// Auto-refresh every 10 seconds
refreshInterval = window.setInterval(() => {
fetchStatus()
}, 10000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script> </script>
<template> <template>
<div class="p-6"> <div class="min-h-screen bg-neutral-950">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Server Status</h1> <!-- Header -->
<p class="text-neutral-400">Live server status, uptime history, and current player count.</p> <header class="bg-neutral-900 border-b border-neutral-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-neutral-100 flex items-center gap-3">
<Activity class="w-8 h-8 text-oxide-500" />
Corrosion Status
</h1>
<p class="text-neutral-400 mt-1">
Real-time status for all Corrosion-powered Rust servers
</p>
</div>
<!-- Search -->
<div class="relative w-full md:w-80">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery"
type="text"
placeholder="Search servers..."
class="w-full pl-10 pr-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
</div>
<!-- Platform Health Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6" v-if="!loading">
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
<Server class="w-3.5 h-3.5" />
Total Servers
</div>
<p class="text-2xl font-bold text-neutral-100">
{{ platformHealth.total_servers }}
</p>
</div>
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
<Activity class="w-3.5 h-3.5" />
Online Now
</div>
<p class="text-2xl font-bold text-green-400">
{{ platformHealth.online_servers }}
</p>
</div>
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
<Users class="w-3.5 h-3.5" />
Total Players
</div>
<p class="text-2xl font-bold text-oxide-400">
{{ platformHealth.total_players }}
</p>
</div>
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
<TrendingUp class="w-3.5 h-3.5" />
Platform Uptime
</div>
<p class="text-2xl font-bold text-neutral-100">
{{ platformHealth.uptime_percent.toFixed(1) }}%
</p>
</div>
</div>
</div>
</header>
<!-- Server Grid -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<div class="inline-block w-8 h-8 border-4 border-oxide-500/20 border-t-oxide-500 rounded-full animate-spin"></div>
<p class="text-neutral-400 mt-4">Loading server status...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-500/10 border border-red-500/20 rounded-lg p-6 text-center">
<p class="text-red-400">{{ error }}</p>
</div>
<!-- Empty State -->
<div v-else-if="filteredServers.length === 0" class="text-center py-12">
<Server class="w-16 h-16 text-neutral-600 mx-auto mb-4" />
<p class="text-neutral-400 text-lg">
{{ searchQuery ? 'No servers match your search' : 'No servers available yet' }}
</p>
</div>
<!-- Server Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="server in filteredServers"
:key="server.subdomain"
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-oxide-500/50 transition-colors"
>
<!-- Server Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-neutral-100 truncate">
{{ server.server_name }}
</h3>
<a
:href="`https://${server.subdomain}.corrosionmgmt.com`"
target="_blank"
class="text-xs text-oxide-400 hover:text-oxide-300 truncate block"
>
{{ server.subdomain }}.corrosionmgmt.com
</a>
</div>
<!-- Status Indicator -->
<div class="flex items-center gap-2 ml-3">
<div
:class="getStatusColor(server.status)"
class="w-2.5 h-2.5 rounded-full animate-pulse"
></div>
<span class="text-xs font-medium text-neutral-300">
{{ getStatusText(server.status) }}
</span>
</div>
</div>
<!-- Description -->
<p v-if="server.description" class="text-sm text-neutral-400 mb-4 line-clamp-2">
{{ server.description }}
</p>
<!-- Player Count -->
<div class="flex items-center gap-2 mb-4">
<Users class="w-4 h-4 text-neutral-500" />
<span class="text-sm text-neutral-300">
{{ server.player_count }} / {{ server.max_players }} players
</span>
<div class="flex-1 bg-neutral-800 rounded-full h-1.5 overflow-hidden">
<div
class="h-full bg-oxide-500 transition-all duration-300"
:style="{ width: `${(server.player_count / server.max_players) * 100}%` }"
></div>
</div>
</div>
<!-- Map Info -->
<div v-if="server.map_name" class="text-xs text-neutral-500 mb-3">
Map: {{ server.map_name }}
</div>
<!-- Wipe Schedule -->
<div v-if="server.wipe_schedule" class="bg-neutral-800 rounded-lg p-3 mb-3">
<div class="text-xs text-neutral-500 mb-1">Wipe Schedule</div>
<div class="text-sm text-neutral-300">{{ server.wipe_schedule }}</div>
<div v-if="server.next_wipe" class="text-xs text-oxide-400 mt-1">
Next wipe: {{ formatTimeUntil(server.next_wipe) }}
</div>
</div>
<!-- Uptime Badges -->
<div class="flex gap-2">
<div :class="getUptimeBadgeColor(server.uptime_24h_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
<div class="text-xs font-medium">{{ server.uptime_24h_percent.toFixed(1) }}%</div>
<div class="text-[10px] opacity-75">24h</div>
</div>
<div :class="getUptimeBadgeColor(server.uptime_7d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
<div class="text-xs font-medium">{{ server.uptime_7d_percent.toFixed(1) }}%</div>
<div class="text-[10px] opacity-75">7d</div>
</div>
<div :class="getUptimeBadgeColor(server.uptime_30d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
<div class="text-xs font-medium">{{ server.uptime_30d_percent.toFixed(1) }}%</div>
<div class="text-[10px] opacity-75">30d</div>
</div>
</div>
</div>
</div>
<!-- Auto-refresh indicator -->
<div class="text-center mt-8 text-xs text-neutral-600">
Auto-refreshing every 10 seconds
</div>
</main>
<!-- Footer -->
<footer class="bg-neutral-900 border-t border-neutral-800 mt-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center">
<p class="text-neutral-400 text-sm mb-2">
Powered by
<a
href="https://panel.corrosionmgmt.com"
class="text-oxide-400 hover:text-oxide-300 font-medium"
target="_blank"
>
Corrosion
</a>
</p>
<p class="text-neutral-600 text-xs">
The complete server management platform for Rust game servers
</p>
</div>
</footer>
</div> </div>
</template> </template>