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))

579
web/edit.html Normal file
View File

@@ -0,0 +1,579 @@
<!doctype html>
<html class="dark" lang="es" dir="ltr">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Editar Aplicación - SIAX Monitor</title>
<link rel="icon" type="image/svg+xml" href="/static/icon/favicon.svg" />
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet"
/>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#137fec",
"background-light": "#f6f7f8",
"background-dark": "#101922",
},
fontFamily: { display: ["Inter", "sans-serif"] },
borderRadius: {
DEFAULT: "0.25rem",
lg: "0.5rem",
xl: "0.75rem",
full: "9999px",
},
},
},
};
</script>
<style>
body {
font-family: "Inter", sans-serif;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
}
</style>
</head>
<body
class="bg-background-light dark:bg-background-dark font-display text-white min-h-screen flex flex-col"
>
<!-- Sticky Top Navigation -->
<header
class="sticky top-0 z-50 w-full border-b border-solid border-[#283039] bg-background-dark/80 backdrop-blur-md px-4 md:px-10 py-3"
>
<div
class="max-w-[1200px] mx-auto flex items-center justify-between whitespace-nowrap"
>
<div class="flex items-center gap-8">
<div class="flex items-center gap-4 text-white">
<div
class="size-8 bg-primary rounded-lg flex items-center justify-center"
>
<img
src="/static/icon/logo.png"
alt="Logo"
class="w-full h-full object-cover"
/>
</div>
<h2
class="text-white text-lg font-bold leading-tight tracking-[-0.015em]"
>
SIAX Monitor
</h2>
</div>
</div>
<div class="flex flex-1 justify-end gap-6 items-center">
<nav class="hidden md:flex items-center gap-6">
<a
class="text-[#9dabb9] text-sm font-medium hover:text-white transition-colors"
href="/"
>Panel</a
>
<a
class="text-[#9dabb9] text-sm font-medium hover:text-white transition-colors"
href="/scan"
>Escanear</a
>
<a
class="text-[#9dabb9] text-sm font-medium hover:text-white transition-colors"
href="/select"
>Selecionar Detectada</a
>
<a
class="text-[#9dabb9] text-sm font-medium hover:text-white transition-colors"
href="/logs"
>Registros</a
>
</nav>
<button
class="hidden lg:flex cursor-pointer items-center justify-center rounded-lg h-9 px-4 bg-primary text-white text-sm font-bold transition-opacity hover:opacity-90"
onclick="window.location.href = '/register'"
>
<span>Nueva App</span>
</button>
</div>
</div>
</header>
<main class="flex-1 max-w-[900px] mx-auto w-full px-4 py-8 space-y-6">
<!-- Page Heading -->
<div class="flex flex-col gap-2">
<h1
class="text-white text-4xl font-black leading-tight tracking-[-0.033em]"
>
Register New Application
</h1>
<p class="text-[#9dabb9] text-base font-normal">
Register a Node.js or Python application to manage with
systemd.
</p>
</div>
<!-- Alert Messages -->
<div
id="alert-success"
class="hidden rounded-xl p-4 bg-green-500/20 border border-green-500/30"
>
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-green-400"
>check_circle</span
>
<p
class="text-green-400 text-sm font-medium"
id="success-message"
></p>
</div>
</div>
<div
id="alert-error"
class="hidden rounded-xl p-4 bg-red-500/20 border border-red-500/30"
>
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-red-400"
>error</span
>
<p
class="text-red-400 text-sm font-medium"
id="error-message"
></p>
</div>
</div>
<!-- Registration Form -->
<form id="registerForm" class="space-y-6">
<!-- Basic Information Card -->
<div
class="rounded-xl border border-[#283039] bg-[#161f2a] p-6 space-y-5"
>
<h3 class="text-white text-lg font-bold">
Basic Information
</h3>
<div class="space-y-2">
<label class="block text-[#9dabb9] text-sm font-medium">
Application Name <span class="text-red-400">*</span>
</label>
<input
type="text"
id="app_name"
name="app_name"
required
placeholder="mi-app"
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p class="text-[#9dabb9] text-xs">
Solo letras, números, guiones y guiones bajos
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-2">
<label
class="block text-[#9dabb9] text-sm font-medium"
>
Application Type
<span class="text-red-400">*</span>
</label>
<select
id="app_type"
name="app_type"
required
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="nodejs">Node.js</option>
<option value="python">Python / FastAPI</option>
</select>
</div>
<div class="space-y-2">
<label
class="block text-[#9dabb9] text-sm font-medium"
>
Restart Policy
<span class="text-red-400">*</span>
</label>
<select
id="restart_policy"
name="restart_policy"
required
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="always">
Always (Always restart)
</option>
<option value="on-failure">
On-Failure (Only if fails)
</option>
<option value="no">No (No reiniciar)</option>
</select>
</div>
</div>
<div class="space-y-2">
<label class="block text-[#9dabb9] text-sm font-medium">
Description
</label>
<textarea
id="description"
name="description"
rows="2"
placeholder="Descripción de la aplicación..."
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
></textarea>
</div>
</div>
<!-- Paths & User Card -->
<div
class="rounded-xl border border-[#283039] bg-[#161f2a] p-6 space-y-5"
>
<h3 class="text-white text-lg font-bold">
Rutas y Usuario
</h3>
<div class="space-y-2">
<label class="block text-[#9dabb9] text-sm font-medium">
Script Path <span class="text-red-400">*</span>
</label>
<input
type="text"
id="script_path"
name="script_path"
required
placeholder="/opt/apps/mi-app/index.js"
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p class="text-[#9dabb9] text-xs">
Ruta completa al archivo principal (.js o .py)
</p>
</div>
<div class="space-y-2">
<label class="block text-[#9dabb9] text-sm font-medium">
Working Directory
<span class="text-red-400">*</span>
</label>
<input
type="text"
id="working_directory"
name="working_directory"
required
placeholder="/opt/apps/mi-app"
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p class="text-[#9dabb9] text-xs">
Directorio desde el cual se ejecutará la aplicación
</p>
</div>
<div class="space-y-2">
<label class="block text-[#9dabb9] text-sm font-medium">
System User <span class="text-red-400">*</span>
</label>
<input
type="text"
id="user"
name="user"
required
placeholder="nodejs"
value="nodejs"
class="w-full bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p class="text-[#9dabb9] text-xs">
Usuario bajo el cual se ejecutará el proceso
</p>
</div>
</div>
<!-- Environment Variables Card -->
<div
class="rounded-xl border border-[#283039] bg-[#161f2a] p-6 space-y-5"
>
<div class="flex items-center justify-between">
<h3 class="text-white text-lg font-bold">
Environment Variables
</h3>
<button
type="button"
onclick="addEnvVar()"
class="flex items-center gap-2 px-4 py-2 bg-[#1c2730] hover:bg-[#283039] border border-[#283039] rounded-lg text-white text-sm font-medium transition-colors"
>
<span class="material-symbols-outlined text-[18px]"
>add</span
>
Add Variable
</button>
</div>
<div id="env-container" class="space-y-3">
<div
class="env-item grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3"
>
<input
type="text"
placeholder="KEY"
class="env-key bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<input
type="text"
placeholder="valor"
class="env-value bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
type="button"
onclick="removeEnvVar(this)"
class="flex items-center justify-center w-full md:w-auto px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 border border-red-500/30 rounded-lg text-red-400 transition-colors"
>
<span
class="material-symbols-outlined text-[18px]"
>delete</span
>
</button>
</div>
</div>
</div>
<!-- Action Buttons -->
<div
class="flex flex-col-reverse sm:flex-row gap-3 justify-between pt-4"
>
<button
type="button"
onclick="window.location.href = '/'"
class="flex items-center justify-center rounded-lg h-12 px-6 bg-[#1c2730] hover:bg-[#283039] border border-[#283039] text-white text-sm font-bold transition-colors"
>
Cancel
</button>
<button
type="submit"
class="flex items-center justify-center rounded-lg h-12 px-6 bg-primary hover:brightness-110 text-white text-sm font-bold transition-all gap-2"
>
<span class="material-symbols-outlined text-[18px]"
>check_circle</span
>
<span>Editar Aplicación</span>
</button>
</div>
</form>
</main>
<script>
function addEnvVar() {
const container = document.getElementById("env-container");
const envItem = document.createElement("div");
envItem.className =
"env-item grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3";
envItem.innerHTML = `
<input
type="text"
placeholder="KEY"
class="env-key bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<input
type="text"
placeholder="valor"
class="env-value bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
type="button"
onclick="removeEnvVar(this)"
class="flex items-center justify-center w-full md:w-auto px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 border border-red-500/30 rounded-lg text-red-400 transition-colors"
>
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
`;
container.appendChild(envItem);
}
function removeEnvVar(btn) {
const container = document.getElementById("env-container");
if (container.children.length > 1) {
btn.closest(".env-item").remove();
}
}
function showAlert(message, type) {
const successAlert = document.getElementById("alert-success");
const errorAlert = document.getElementById("alert-error");
if (type === "success") {
document.getElementById("success-message").textContent =
message;
successAlert.classList.remove("hidden");
errorAlert.classList.add("hidden");
} else {
document.getElementById("error-message").textContent =
message;
errorAlert.classList.remove("hidden");
successAlert.classList.add("hidden");
}
// Scroll to top
window.scrollTo({ top: 0, behavior: "smooth" });
// Hide after 5 seconds
setTimeout(() => {
successAlert.classList.add("hidden");
errorAlert.classList.add("hidden");
}, 5000);
}
// Obtener nombre de app desde URL
const urlParams = new URLSearchParams(window.location.search);
const appName = urlParams.get("app");
if (!appName) {
alert("No se especificó el nombre de la aplicación");
window.location.href = "/";
}
// Cargar datos de la app
async function loadAppData() {
try {
const response = await fetch(`/api/apps/${appName}`);
const result = await response.json();
if (!result.success) {
alert("Error: " + result.error);
window.location.href = "/";
return;
}
const app = result.data;
// Llenar formulario con datos actuales
document.getElementById("app_name").value = app.name;
document.getElementById("app_name").readOnly = true; // No cambiar nombre
document.getElementById("script_path").value =
app.entry_point;
document.getElementById("working_directory").value =
app.path;
document.getElementById("user").value =
app.user || "pablinux";
document.getElementById("restart_policy").value = "always";
document.getElementById("app_type").value = "nodejs";
document.getElementById("description").value = "";
// Cargar variables de entorno (si las hay guardadas)
// Por ahora mostramos un campo vacío, el .env se cargará automáticamente
} catch (error) {
console.error("Error cargando app:", error);
alert("Error al cargar los datos de la aplicación");
}
}
loadAppData();
document
.getElementById("registerForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = {
app_name: document.getElementById("app_name").value,
script_path:
document.getElementById("script_path").value,
working_directory:
document.getElementById("working_directory").value,
user: document.getElementById("user").value,
environment: {},
restart_policy:
document.getElementById("restart_policy").value,
app_type: document.getElementById("app_type").value,
description:
document.getElementById("description").value ||
null,
};
// Collect environment variables
const envItems = document.querySelectorAll(".env-item");
envItems.forEach((item) => {
const key = item.querySelector(".env-key").value;
const value = item.querySelector(".env-value").value;
if (key && value) {
formData.environment[key] = value;
}
});
try {
const response = await fetch(`/api/apps/${appName}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await response.json();
if (result.success) {
showAlert(
`✅ Aplicación actualizada: ${formData.app_name}`,
"success",
);
setTimeout(() => {
window.location.href = "/";
}, 2000);
// Reset form
document.getElementById("registerForm").reset();
document.getElementById("env-container").innerHTML =
`
<div class="env-item grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3">
<input type="text" placeholder="KEY" class="env-key bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
<input type="text" placeholder="valor" class="env-value bg-[#1c2730] border border-[#283039] rounded-lg px-4 py-2.5 text-white placeholder-[#9dabb9] focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
<button type="button" onclick="removeEnvVar(this)" class="flex items-center justify-center w-full md:w-auto px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 border border-red-500/30 rounded-lg text-red-400 transition-colors">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
`;
} else {
showAlert(
"Error: " +
(result.error || "Error desconocido"),
"error",
);
}
} catch (error) {
showAlert(
"Connection error: " + error.message,
"error",
);
}
});
// Auto-fill working_directory based on script_path
document
.getElementById("script_path")
.addEventListener("blur", function () {
const scriptPath = this.value;
const workingDirInput =
document.getElementById("working_directory");
if (scriptPath && !workingDirInput.value) {
const dir = scriptPath.substring(
0,
scriptPath.lastIndexOf("/"),
);
workingDirInput.value = dir;
}
});
</script>
</body>
</html>

View File

@@ -792,6 +792,11 @@
</button>
`
}
<button class="text-purple-400 hover:text-purple-300 transition-colors p-1.5 rounded hover:bg-purple-900/20"
onclick="window.location.href='/edit?app=${app.name}'"
title="Editar">
<span class="material-symbols-outlined text-[20px]">edit</span>
</button>
<button class="text-blue-400 hover:text-blue-300 transition-colors p-1.5 rounded hover:bg-blue-900/20"
onclick="window.location.href='/logs'"
title="Ver logs">