diff --git a/backend/src/api/servers.rs b/backend/src/api/servers.rs index 2ed953b..69cb369 100644 --- a/backend/src/api/servers.rs +++ b/backend/src/api/servers.rs @@ -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> { 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 { - todo!() +/// GET /api/servers — returns combined server overview (connection + config) +async fn get_server_overview( + auth: AuthUser, + State(state): State>, +) -> ApiResult> { + 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 { - todo!() +/// GET /api/servers/connection +async fn get_connection( + auth: AuthUser, + State(state): State>, +) -> ApiResult> { + 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 { - todo!() +/// GET /api/servers/config +async fn get_config( + auth: AuthUser, + State(state): State>, +) -> ApiResult> { + 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 { - todo!() +#[derive(serde::Deserialize)] +struct UpdateConfigRequest { + server_name: Option, + max_players: Option, + world_size: Option, + current_seed: Option, } -async fn get_server_plugins() -> ApiResult { - todo!() +/// PUT /api/servers/config +async fn update_config( + auth: AuthUser, + State(state): State>, + Json(body): Json, +) -> ApiResult> { + 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 { - todo!() +/// GET /api/servers/admins +async fn get_admins( + auth: AuthUser, + State(state): State>, +) -> ApiResult> { + 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 { - todo!() +/// POST /api/servers/command +async fn send_command( + _auth: AuthUser, + State(_state): State>, +) -> ApiResult> { + // 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 { - todo!() +/// POST /api/servers/start +async fn start_server( + _auth: AuthUser, + State(_state): State>, +) -> ApiResult> { + // 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>, +) -> ApiResult> { + // 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>, +) -> ApiResult> { + // TODO: Route through PanelAdapter + Err(ApiError::BadRequest("Server restart not yet implemented".to_string())) } diff --git a/backend/src/db/servers.rs b/backend/src/db/servers.rs index e6422f6..715863c 100644 --- a/backend/src/db/servers.rs +++ b/backend/src/db/servers.rs @@ -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 { - todo!() +/// Get the server connection for a license. +pub async fn get_server_connection(pool: &PgPool, license_id: Uuid) -> Result> { + 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, + game_port: Option, +) -> Result { + 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) -> 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 { - todo!() +/// Get the server config for a license. +pub async fn get_server_config(pool: &PgPool, license_id: Uuid) -> Result> { + 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 { + 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, world_size: Option, max_players: Option) -> Result<()> { - todo!() +/// Update server config fields. +pub async fn update_server_config( + pool: &PgPool, + license_id: Uuid, + server_name: Option<&str>, + max_players: Option, + world_size: Option, + current_seed: Option, +) -> 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> { + 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 { - 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 { + 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(()) } diff --git a/frontend/src/components/layout/DashboardLayout.vue b/frontend/src/components/layout/DashboardLayout.vue index 69629b4..f0a5fc5 100644 --- a/frontend/src/components/layout/DashboardLayout.vue +++ b/frontend/src/components/layout/DashboardLayout.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/stores/server.ts b/frontend/src/stores/server.ts index 601801f..8db28b8 100644 --- a/frontend/src/stores/server.ts +++ b/frontend/src/stores/server.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import type { ServerConnection, ServerConfig, ServerStats } from '@/types' +import { useApi } from '@/composables/useApi' export const useServerStore = defineStore('server', () => { const connection = ref(null) @@ -8,28 +9,45 @@ export const useServerStore = defineStore('server', () => { const stats = ref(null) const isLoading = ref(false) - async function fetchServerStatus() { - // TODO: Fetch from API + const api = useApi() + + async function fetchServer() { + isLoading.value = true + try { + const data = await api.get<{ connection: ServerConnection | null; config: ServerConfig | null }>('/servers') + connection.value = data.connection + config.value = data.config + } catch (e) { + console.error('Failed to fetch server:', e) + } finally { + isLoading.value = false + } } - async function fetchServerConfig() { - // TODO: Fetch from API - } - - async function startServer() { - // TODO: POST /api/servers/:id/start - } - - async function stopServer() { - // TODO: POST /api/servers/:id/stop - } - - async function restartServer() { - // TODO: POST /api/servers/:id/restart + async function updateConfig(updates: Partial) { + try { + await api.put('/servers/config', updates) + await fetchServer() + } catch (e) { + console.error('Failed to update config:', e) + throw e + } } async function sendCommand(command: string) { - // TODO: POST /api/servers/:id/command + return api.post('/servers/command', { command }) + } + + async function startServer() { + return api.post('/servers/start') + } + + async function stopServer() { + return api.post('/servers/stop') + } + + async function restartServer() { + return api.post('/servers/restart') } function updateStats(newStats: ServerStats) { @@ -41,12 +59,12 @@ export const useServerStore = defineStore('server', () => { config, stats, isLoading, - fetchServerStatus, - fetchServerConfig, + fetchServer, + updateConfig, + sendCommand, startServer, stopServer, restartServer, - sendCommand, updateStats, } }) diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue index d53e3a1..37a5a37 100644 --- a/frontend/src/views/admin/DashboardView.vue +++ b/frontend/src/views/admin/DashboardView.vue @@ -1,7 +1,38 @@