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:
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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:**
|
||||||
|
|||||||
@@ -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"})))
|
||||||
}
|
}
|
||||||
|
|||||||
85
backend/src/api/settings.rs
Normal file
85
backend/src/api/settings.rs
Normal 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
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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
75
backend/src/models/public.rs
Normal file
75
backend/src/models/public.rs
Normal 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>,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user