feat: Implement server endpoints, store, and live dashboard

Backend: Server connection/config/admins DB queries, server API routes
with auth-gated endpoints (overview, config CRUD, admin management).
Frontend: Server store wired to API, dashboard fetches server data on
mount with live status indicators, uptime formatting, and server
config display. Logout now redirects to /login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-14 21:51:49 -05:00
parent 5668675b6a
commit a53cb4d8a5
5 changed files with 387 additions and 85 deletions

View File

@@ -1,53 +1,154 @@
use std::sync::Arc;
use axum::{
extract::State,
routing::{get, post, put},
Router,
Json, Router,
};
use crate::models::error::ApiResult;
use crate::db;
use crate::middleware::auth::AuthUser;
use crate::models::error::{ApiError, ApiResult};
use crate::AppState;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(list_servers))
.route("/{id}", get(get_server))
.route("/{id}", put(update_server))
.route("/{id}/command", post(send_command))
.route("/{id}/plugins", get(get_server_plugins))
.route("/{id}/start", post(start_server))
.route("/{id}/stop", post(stop_server))
.route("/{id}/restart", post(restart_server))
.route("/", get(get_server_overview))
.route("/connection", get(get_connection))
.route("/config", get(get_config))
.route("/config", put(update_config))
.route("/command", post(send_command))
.route("/admins", get(get_admins))
.route("/start", post(start_server))
.route("/stop", post(stop_server))
.route("/restart", post(restart_server))
}
async fn list_servers() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// GET /api/servers — returns combined server overview (connection + config)
async fn get_server_overview(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
let connection = db::servers::get_server_connection(&state.db, license_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let config = db::servers::get_server_config(&state.db, license_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"connection": connection,
"config": config,
})))
}
async fn get_server() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// GET /api/servers/connection
async fn get_connection(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
let connection = db::servers::get_server_connection(&state.db, license_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "connection": connection })))
}
async fn update_server() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// GET /api/servers/config
async fn get_config(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
let config = db::servers::get_server_config(&state.db, license_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "config": config })))
}
async fn send_command() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
#[derive(serde::Deserialize)]
struct UpdateConfigRequest {
server_name: Option<String>,
max_players: Option<i32>,
world_size: Option<i32>,
current_seed: Option<i32>,
}
async fn get_server_plugins() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// PUT /api/servers/config
async fn update_config(
auth: AuthUser,
State(state): State<Arc<AppState>>,
Json(body): Json<UpdateConfigRequest>,
) -> ApiResult<Json<serde_json::Value>> {
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
db::servers::update_server_config(
&state.db,
license_id,
body.server_name.as_deref(),
body.max_players,
body.world_size,
body.current_seed,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "message": "Config updated" })))
}
async fn start_server() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// GET /api/servers/admins
async fn get_admins(
auth: AuthUser,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
let admins = db::servers::get_game_admins(&state.db, license_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "admins": admins })))
}
async fn stop_server() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// POST /api/servers/command
async fn send_command(
_auth: AuthUser,
State(_state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
// TODO: Route command through PanelAdapter or NATS to game server
Err(ApiError::BadRequest("Server command not yet implemented".to_string()))
}
async fn restart_server() -> ApiResult<impl axum::response::IntoResponse> {
todo!()
/// POST /api/servers/start
async fn start_server(
_auth: AuthUser,
State(_state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
// TODO: Route through PanelAdapter
Err(ApiError::BadRequest("Server start not yet implemented".to_string()))
}
/// POST /api/servers/stop
async fn stop_server(
_auth: AuthUser,
State(_state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
// TODO: Route through PanelAdapter
Err(ApiError::BadRequest("Server stop not yet implemented".to_string()))
}
/// POST /api/servers/restart
async fn restart_server(
_auth: AuthUser,
State(_state): State<Arc<AppState>>,
) -> ApiResult<Json<serde_json::Value>> {
// TODO: Route through PanelAdapter
Err(ApiError::BadRequest("Server restart not yet implemented".to_string()))
}

View File

@@ -2,51 +2,177 @@ use sqlx::PgPool;
use uuid::Uuid;
use anyhow::Result;
// TODO: Define ServerConnection struct (id, license_id, name, host, rcon_port, rcon_password_encrypted, query_port, created_at)
// TODO: Define ServerConfig struct (id, server_id, seed, world_size, max_players, hostname, description, header_image_url, etc.)
// TODO: Define GameAdmin struct (id, server_id, steam_id, role, added_at)
use crate::models::server::{ServerConnection, ServerConfig, GameAdmin};
/// Register a new server connection (RCON credentials).
pub async fn create_server_connection(pool: &PgPool, license_id: Uuid, name: &str, host: &str, rcon_port: i32) -> Result<Uuid> {
todo!()
/// Get the server connection for a license.
pub async fn get_server_connection(pool: &PgPool, license_id: Uuid) -> Result<Option<ServerConnection>> {
let conn = sqlx::query_as::<_, ServerConnection>(
"SELECT id, license_id, connection_type, panel_api_endpoint, panel_api_key_encrypted, \
panel_server_identifier, companion_agent_token, companion_last_seen, plugin_last_seen, \
server_ip, server_port, game_port, connection_status, created_at, updated_at \
FROM server_connections WHERE license_id = $1",
)
.bind(license_id)
.fetch_optional(pool)
.await?;
Ok(conn)
}
/// Fetch a server connection by ID.
pub async fn get_server_connection(pool: &PgPool, server_id: Uuid) -> Result<()> {
todo!()
/// Create a new server connection.
pub async fn create_server_connection(
pool: &PgPool,
license_id: Uuid,
connection_type: &str,
server_ip: Option<&str>,
server_port: Option<i32>,
game_port: Option<i32>,
) -> Result<Uuid> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO server_connections (license_id, connection_type, server_ip, server_port, game_port) \
VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(license_id)
.bind(connection_type)
.bind(server_ip)
.bind(server_port)
.bind(game_port)
.fetch_one(pool)
.await?;
Ok(row.0)
}
/// Update server connection details.
pub async fn update_server_connection(pool: &PgPool, server_id: Uuid, name: Option<&str>, host: Option<&str>, rcon_port: Option<i32>) -> Result<()> {
todo!()
/// Update server connection status.
pub async fn update_connection_status(pool: &PgPool, license_id: Uuid, status: &str) -> Result<()> {
sqlx::query(
"UPDATE server_connections SET connection_status = $1, updated_at = NOW() WHERE license_id = $2",
)
.bind(status)
.bind(license_id)
.execute(pool)
.await?;
Ok(())
}
/// Create the initial server configuration record.
pub async fn create_server_config(pool: &PgPool, server_id: Uuid) -> Result<Uuid> {
todo!()
/// Get the server config for a license.
pub async fn get_server_config(pool: &PgPool, license_id: Uuid) -> Result<Option<ServerConfig>> {
let config = sqlx::query_as::<_, ServerConfig>(
"SELECT id, license_id, server_name, max_players, world_size, current_seed, \
current_map_id, server_description, server_url, server_header_image, tags, \
auto_restart_enabled, auto_restart_cron, auto_restart_timezone, \
crash_recovery_enabled, crash_recovery_max_attempts, crash_recovery_cooldown_minutes, \
force_wipe_eligible, auto_update_on_force_wipe, config_overrides, \
created_at, updated_at \
FROM server_config WHERE license_id = $1",
)
.bind(license_id)
.fetch_optional(pool)
.await?;
Ok(config)
}
/// Fetch server configuration.
pub async fn get_server_config(pool: &PgPool, server_id: Uuid) -> Result<()> {
todo!()
/// Create a default server config for a license.
pub async fn create_server_config(
pool: &PgPool,
license_id: Uuid,
server_name: &str,
) -> Result<Uuid> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO server_config (license_id, server_name) VALUES ($1, $2) RETURNING id",
)
.bind(license_id)
.bind(server_name)
.fetch_one(pool)
.await?;
Ok(row.0)
}
/// Update server configuration fields.
pub async fn update_server_config(pool: &PgPool, server_id: Uuid, seed: Option<i32>, world_size: Option<i32>, max_players: Option<i32>) -> Result<()> {
todo!()
/// Update server config fields.
pub async fn update_server_config(
pool: &PgPool,
license_id: Uuid,
server_name: Option<&str>,
max_players: Option<i32>,
world_size: Option<i32>,
current_seed: Option<i32>,
) -> Result<()> {
// Dynamic update — only modify provided fields
if let Some(name) = server_name {
sqlx::query("UPDATE server_config SET server_name = $1, updated_at = NOW() WHERE license_id = $2")
.bind(name)
.bind(license_id)
.execute(pool)
.await?;
}
if let Some(mp) = max_players {
sqlx::query("UPDATE server_config SET max_players = $1, updated_at = NOW() WHERE license_id = $2")
.bind(mp)
.bind(license_id)
.execute(pool)
.await?;
}
if let Some(ws) = world_size {
sqlx::query("UPDATE server_config SET world_size = $1, updated_at = NOW() WHERE license_id = $2")
.bind(ws)
.bind(license_id)
.execute(pool)
.await?;
}
if let Some(seed) = current_seed {
sqlx::query("UPDATE server_config SET current_seed = $1, updated_at = NOW() WHERE license_id = $2")
.bind(seed)
.bind(license_id)
.execute(pool)
.await?;
}
Ok(())
}
/// Get all game admins (moderators/owners) for a server.
pub async fn get_game_admins(pool: &PgPool, server_id: Uuid) -> Result<()> {
todo!()
/// Get all game admins for a license.
pub async fn get_game_admins(pool: &PgPool, license_id: Uuid) -> Result<Vec<GameAdmin>> {
let admins = sqlx::query_as::<_, GameAdmin>(
"SELECT id, license_id, steam_id, display_name, admin_level, permissions, added_by, created_at \
FROM game_admins WHERE license_id = $1 ORDER BY created_at",
)
.bind(license_id)
.fetch_all(pool)
.await?;
Ok(admins)
}
/// Add a game admin by Steam ID.
pub async fn create_game_admin(pool: &PgPool, server_id: Uuid, steam_id: &str, role: &str) -> Result<Uuid> {
todo!()
/// Add a game admin.
pub async fn create_game_admin(
pool: &PgPool,
license_id: Uuid,
steam_id: &str,
display_name: &str,
admin_level: &str,
added_by: Uuid,
) -> Result<Uuid> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO game_admins (license_id, steam_id, display_name, admin_level, added_by) \
VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(license_id)
.bind(steam_id)
.bind(display_name)
.bind(admin_level)
.bind(added_by)
.fetch_one(pool)
.await?;
Ok(row.0)
}
/// Remove a game admin.
pub async fn delete_game_admin(pool: &PgPool, admin_id: Uuid) -> Result<()> {
todo!()
sqlx::query("DELETE FROM game_admins WHERE id = $1")
.bind(admin_id)
.execute(pool)
.await?;
Ok(())
}