diff --git a/src/api/handlers.rs b/src/api/handlers.rs index 39d97fb..d9c6591 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -358,3 +358,52 @@ pub async fn get_system_error_logs() -> Result, StatusCo } } } + +/// Endpoint para obtener apps eliminadas (soft delete history) +pub async fn get_deleted_apps_handler() -> Result, StatusCode> { + use crate::config::get_config_manager; + + let config_manager = get_config_manager(); + let deleted_apps = config_manager.get_deleted_apps(); + + // Formatear respuesta con información de cada app eliminada + let apps_info: Vec = deleted_apps.iter().map(|app| { + serde_json::json!({ + "name": app.name, + "port": app.port, + "path": app.path, + "entry_point": app.entry_point, + "mode": app.mode, + "registered_at": app.registered_at, + "deleted_at": app.deleted_at, + "deleted_reason": app.deleted_reason, + }) + }).collect(); + + Ok(Json(serde_json::json!({ + "success": true, + "data": { + "apps": apps_info, + "total": apps_info.len() + } + }))) +} + +/// Endpoint para restaurar una app eliminada (soft delete) +pub async fn restore_app_handler( + Path(app_name): Path, +) -> Result>, StatusCode> { + use crate::config::get_config_manager; + + let config_manager = get_config_manager(); + + match config_manager.restore_app(&app_name) { + Ok(_) => Ok(Json(ApiResponse::success(OperationResponse { + app_name: app_name.clone(), + operation: "restore".to_string(), + success: true, + message: format!("Aplicación '{}' restaurada exitosamente. Nota: el servicio systemd debe ser recreado manualmente.", app_name), + }))), + Err(e) => Ok(Json(ApiResponse::error(e))), + } +} diff --git a/src/config.rs b/src/config.rs index eac9f04..5382b8c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,19 @@ pub struct MonitoredApp { #[serde(default, skip_serializing_if = "String::is_empty", rename = "reg")] pub registered_at: String, + // --- SOFT DELETE FIELDS --- + /// Indica si la app fue eliminada (soft delete) + #[serde(default)] + pub deleted: bool, + + /// Fecha de eliminación (ISO 8601) + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + + /// Razón de eliminación (opcional) + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_reason: Option, + // DEPRECATED: Mantener por compatibilidad con versiones antiguas #[serde(skip_serializing_if = "Option::is_none")] pub systemd_service: Option, @@ -131,11 +144,30 @@ impl ConfigManager { Ok(()) } + /// Obtiene las apps activas (no eliminadas) pub fn get_apps(&self) -> Vec { + let config = self.config.read().unwrap(); + config.apps.iter() + .filter(|app| !app.deleted) + .cloned() + .collect() + } + + /// Obtiene TODAS las apps, incluyendo las eliminadas + pub fn get_all_apps(&self) -> Vec { let config = self.config.read().unwrap(); config.apps.clone() } + /// Obtiene solo las apps eliminadas + pub fn get_deleted_apps(&self) -> Vec { + let config = self.config.read().unwrap(); + config.apps.iter() + .filter(|app| app.deleted) + .cloned() + .collect() + } + /// Agrega una app con información completa pub fn add_app_full(&self, app: MonitoredApp) -> Result<(), String> { let mut config = self.config.write().unwrap(); @@ -170,6 +202,9 @@ impl ConfigManager { mode: "production".to_string(), service_file_path: String::new(), registered_at, + deleted: false, + deleted_at: None, + deleted_reason: None, systemd_service: None, created_at: None, }; @@ -177,6 +212,55 @@ impl ConfigManager { self.add_app_full(app) } + /// Realiza un soft delete: marca la app como eliminada pero mantiene el registro + pub fn soft_delete_app(&self, name: &str, reason: Option) -> Result<(), String> { + let mut config = self.config.write().unwrap(); + + // Buscar la app + let app = config.apps.iter_mut().find(|a| a.name == name && !a.deleted); + + match app { + Some(app) => { + // Marcar como eliminada + app.deleted = true; + app.deleted_at = Some(chrono::Local::now().to_rfc3339()); + app.deleted_reason = reason; + + // Guardar en disco + match Self::save_config_to_file(&self.config_path, &config) { + Ok(_) => Ok(()), + Err(e) => Err(format!("Error al guardar configuración: {}", e)) + } + } + None => Err(format!("La app '{}' no se encontró o ya está eliminada", name)) + } + } + + /// Restaura una app previamente eliminada (soft delete) + pub fn restore_app(&self, name: &str) -> Result<(), String> { + let mut config = self.config.write().unwrap(); + + // Buscar la app eliminada + let app = config.apps.iter_mut().find(|a| a.name == name && a.deleted); + + match app { + Some(app) => { + // Restaurar + app.deleted = false; + app.deleted_at = None; + app.deleted_reason = None; + + // Guardar en disco + match Self::save_config_to_file(&self.config_path, &config) { + Ok(_) => Ok(()), + Err(e) => Err(format!("Error al guardar configuración: {}", e)) + } + } + None => Err(format!("La app '{}' no se encontró en apps eliminadas", name)) + } + } + + /// HARD DELETE: Elimina permanentemente una app del JSON (usar con precaución) pub fn remove_app(&self, name: &str) -> Result<(), String> { let mut config = self.config.write().unwrap(); diff --git a/src/discovery.rs b/src/discovery.rs index d70cd65..f7d202a 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -257,6 +257,9 @@ pub fn sync_discovered_services(services: Vec) { mode: service.node_env, service_file_path: service.service_file.clone(), registered_at, + deleted: false, + deleted_at: None, + deleted_reason: None, systemd_service: None, created_at: None, }; diff --git a/src/main.rs b/src/main.rs index 68882f2..2a5dc42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,11 +70,13 @@ async fn main() { .route("/api/monitored", get(api::get_monitored_apps_handler)) .route("/api/logs/errors", get(api::get_system_error_logs)) .route("/api/apps", get(api::list_apps_handler).post(api::register_app_handler)) + .route("/api/apps/deleted", get(api::get_deleted_apps_handler)) .route("/api/apps/:name", delete(api::unregister_app_handler)) .route("/api/apps/:name/status", get(api::get_app_status_handler)) .route("/api/apps/:name/start", post(api::start_app_handler)) .route("/api/apps/:name/stop", post(api::stop_app_handler)) .route("/api/apps/:name/restart", post(api::restart_app_handler)) + .route("/api/apps/:name/restore", post(api::restore_app_handler)) .route("/api/scan", get(api::scan_processes_handler)) .with_state(api_state); diff --git a/src/orchestrator/app_manager.rs b/src/orchestrator/app_manager.rs index e235e56..8774300 100644 --- a/src/orchestrator/app_manager.rs +++ b/src/orchestrator/app_manager.rs @@ -88,6 +88,9 @@ impl AppManager { mode, service_file_path, registered_at, + deleted: false, + deleted_at: None, + deleted_reason: None, systemd_service: None, created_at: None, }; @@ -104,7 +107,7 @@ impl AppManager { pub fn unregister_app(&self, app_name: &str) -> Result<()> { let logger = get_logger(); - logger.info("AppManager", &format!("Desregistrando aplicación: {}", app_name)); + logger.info("AppManager", &format!("Desregistrando aplicación (soft delete): {}", app_name)); // Obtener configuración let config = self.apps.get(app_name) @@ -119,7 +122,7 @@ impl AppManager { // Deshabilitar el servicio let _ = SystemCtl::disable(&service_name); - // Eliminar archivo de servicio + // Eliminar archivo de servicio (físicamente) ServiceGenerator::delete_service_file(&service_name)?; // Recargar daemon @@ -128,13 +131,14 @@ impl AppManager { // Eliminar de memoria self.apps.remove(app_name); - // Eliminar de monitored_apps.json + // SOFT DELETE en monitored_apps.json (mantener historial) let config_manager = get_config_manager(); - if let Err(e) = config_manager.remove_app(app_name) { - logger.warning("AppManager", "No se pudo eliminar de monitored_apps.json", Some(&e)); + let delete_reason = Some("Eliminada desde el panel de control".to_string()); + if let Err(e) = config_manager.soft_delete_app(app_name, delete_reason) { + logger.warning("AppManager", "No se pudo hacer soft delete en monitored_apps.json", Some(&e)); } - logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente", app_name)); + logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente (soft delete)", app_name)); Ok(()) } diff --git a/web/index.html b/web/index.html index d8cd64a..51b2a3e 100644 --- a/web/index.html +++ b/web/index.html @@ -397,6 +397,100 @@ > + + + +
{ + const deletedDate = app.deleted_at + ? new Date(app.deleted_at).toLocaleString( + "es-ES", + ) + : "Desconocida"; + const reason = + app.deleted_reason || "Sin razón especificada"; + + return ` + + +
+
+ deployed_code_history +
+
+

${app.name}

+

${app.path || "Sin ruta"}

+
+
+ + + ${app.port} + + + ${deletedDate} + + + ${reason} + + + + + + `; + }) + .join(""); + + emptyMessage.classList.add("hidden"); + } catch (error) { + console.error("Error loading deleted apps:", error); + } + } + + function toggleDeletedApps() { + const content = document.getElementById("deleted-apps-content"); + content.classList.toggle("hidden"); + } + + async function restoreApp(appName) { + const confirmed = confirm( + `¿Estás seguro de restaurar la aplicación "${appName}"?\n\nNota: Solo se restaurará el registro en el JSON. El servicio systemd debe ser recreado manualmente.`, + ); + if (!confirmed) return; + + try { + const response = await fetch( + `/api/apps/${appName}/restore`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + const result = await response.json(); + + if (result.success) { + alert(`✅ ${result.data.message}`); + loadApps(); + loadDeletedApps(); + } else { + alert(`❌ Error: ${result.error}`); + } + } catch (error) { + console.error("Error:", error); + alert("❌ Error al restaurar la aplicación"); + } + } + + window.addEventListener("DOMContentLoaded", () => { + loadApps(); + loadDeletedApps(); + });