feat: Implementación completa de funcionalidad EDITAR apps (CRUD Update)

Backend:
- Endpoint PUT /api/apps/:name para actualizar configuración completa
- Endpoint GET /api/apps/:name para obtener datos de una app específica
- update_app_handler(): detiene servicio, regenera .service, daemon-reload, actualiza JSON, reinicia
- Soft delete de versión anterior al actualizar (mantiene historial)
- Logs detallados en cada paso del proceso de actualización
- Recarga automática de variables desde .env al actualizar

Frontend:
- Nueva página /edit?app=NOMBRE para editar apps
- Formulario pre-poblado con datos actuales de la app
- Nombre de app readonly (no se puede cambiar para evitar inconsistencias)
- Botón Editar (morado) en panel principal junto a logs/eliminar
- PUT en lugar de POST, mensaje de éxito actualizado
- Redirección automática al panel después de 2 segundos

Casos de uso resueltos:
 Cambiar usuario (ej: GatewaySIGMA con usuario incorrecto)
 Actualizar puerto
 Modificar variables de entorno
 Cambiar política de reinicio
 Actualizar ruta del script
 Recargar .env sin eliminar la app

Completa el patrón CRUD: Create, Read, Update, Delete 
This commit is contained in:
2026-01-21 17:40:37 -05:00
parent d8b3214ede
commit d2b8d0222c
4 changed files with 756 additions and 1 deletions

View File

@@ -60,6 +60,153 @@ pub async fn register_app_handler(
}
}
pub async fn update_app_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
Json(payload): Json<RegisterAppRequest>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
use crate::config::get_config_manager;
use crate::systemd::{SystemCtl, ServiceGenerator};
let logger = crate::logger::get_logger();
logger.info("API", &format!("✏️ Solicitud de actualización para: {}", app_name));
// Validar que el app_name coincida
if app_name != payload.app_name {
return Ok(Json(ApiResponse::error(
"El nombre de la app en la URL no coincide con el payload".to_string()
)));
}
// Parsear tipo de aplicación
let app_type = match payload.app_type.to_lowercase().as_str() {
"nodejs" | "node" => AppType::NodeJs,
"python" | "py" => AppType::Python,
_ => return Ok(Json(ApiResponse::error(
"Tipo de aplicación inválido. Use 'nodejs' o 'python'".to_string()
))),
};
// Parsear política de reinicio
let restart_policy = match payload.restart_policy.to_lowercase().as_str() {
"always" => RestartPolicy::Always,
"on-failure" | "onfailure" => RestartPolicy::OnFailure,
"no" | "never" => RestartPolicy::No,
_ => RestartPolicy::Always,
};
let config = ServiceConfig {
app_name: payload.app_name.clone(),
script_path: payload.script_path,
working_directory: payload.working_directory,
user: payload.user,
environment: payload.environment,
restart_policy,
app_type,
description: payload.description,
custom_executable: payload.custom_executable,
use_npm_start: payload.use_npm_start,
};
let service_name = format!("siax-app-{}.service", app_name);
// 1. Detener el servicio
logger.info("API", &format!("🛑 Deteniendo servicio: {}", service_name));
let _ = SystemCtl::stop(&service_name);
// 2. Regenerar el archivo .service
logger.info("API", "📝 Regenerando archivo .service con nueva configuración");
match ServiceGenerator::create_service(&config) {
Ok(service_content) => {
match ServiceGenerator::write_service_file(&config, &service_content) {
Ok(_) => {
logger.info("API", "✅ Archivo .service actualizado");
}
Err(e) => {
return Ok(Json(ApiResponse::error(
format!("Error escribiendo archivo .service: {}", e)
)));
}
}
}
Err(e) => {
return Ok(Json(ApiResponse::error(
format!("Error generando .service: {}", e)
)));
}
}
// 3. Recargar daemon
logger.info("API", "🔄 Ejecutando daemon-reload");
let _ = SystemCtl::daemon_reload();
// 4. Actualizar monitored_apps.json
let config_manager = get_config_manager();
let service_file_path = format!("/etc/systemd/system/{}", service_name);
let port = config.environment.get("PORT")
.and_then(|p| p.parse::<i32>().ok())
.unwrap_or(8080);
let entry_point = std::path::Path::new(&config.script_path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("server.js")
.to_string();
let node_bin = config.custom_executable.clone().unwrap_or_default();
let mode = config.environment.get("NODE_ENV")
.cloned()
.unwrap_or_else(|| "production".to_string());
// Primero intentar hacer soft delete de la app anterior
let _ = config_manager.soft_delete_app(&app_name, Some("Actualizada - versión anterior".to_string()));
// Luego agregar la nueva configuración
let monitored_app = crate::config::MonitoredApp {
name: config.app_name.clone(),
service_name: service_name.clone(),
path: config.working_directory.clone(),
port,
entry_point,
node_bin,
mode,
service_file_path,
registered_at: chrono::Local::now().to_rfc3339(),
deleted: false,
deleted_at: None,
deleted_reason: None,
systemd_service: None,
created_at: None,
};
match config_manager.add_app_full(monitored_app) {
Ok(_) => {
logger.info("API", "✅ JSON actualizado");
}
Err(e) => {
logger.warning("API", &format!("No se pudo actualizar JSON: {}", e), None);
}
}
// 5. Iniciar el servicio nuevamente
logger.info("API", &format!("▶️ Iniciando servicio: {}", service_name));
match SystemCtl::start(&service_name) {
Ok(_) => {
logger.info("API", "✅ Servicio iniciado exitosamente");
}
Err(e) => {
logger.warning("API", &format!("Error al iniciar servicio: {}", e), None);
}
}
Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "update".to_string(),
success: true,
message: format!("Aplicación '{}' actualizada exitosamente", app_name),
})))
}
pub async fn unregister_app_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
@@ -171,6 +318,30 @@ pub async fn restart_app_handler(
}
}
pub async fn get_app_details_handler(
Path(app_name): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
use crate::config::get_config_manager;
let config_manager = get_config_manager();
let apps = config_manager.get_apps();
match apps.iter().find(|a| a.name == app_name) {
Some(app) => {
Ok(Json(serde_json::json!({
"success": true,
"data": app
})))
}
None => {
Ok(Json(serde_json::json!({
"success": false,
"error": format!("Aplicación '{}' no encontrada", app_name)
})))
}
}
}
pub async fn get_app_status_handler(
State(_state): State<Arc<ApiState>>,
Path(app_name): Path<String>,

View File

@@ -71,7 +71,7 @@ async fn main() {
.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", get(api::get_app_details_handler).delete(api::unregister_app_handler).put(api::update_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))