Files
SIAX-MONITOR/src/discovery.rs
pablinux 13b36dda5f feat: Implementación completa de Soft Delete para apps
- Agregados campos deleted, deleted_at, deleted_reason a MonitoredApp
- Implementado soft_delete_app() y restore_app() en ConfigManager
- Modificado get_apps() para filtrar apps eliminadas por defecto
- Agregados métodos get_all_apps() y get_deleted_apps()
- Actualizado unregister_app() para usar soft delete en lugar de hard delete
- Creados endpoints:
  * GET /api/apps/deleted - Ver historial de apps eliminadas
  * POST /api/apps/:name/restore - Restaurar app eliminada
- Agregada sección de Historial en index.html con UI completa
- Botón de restaurar para cada app eliminada
- El servicio systemd se elimina físicamente, solo el JSON mantiene historial
- Permite auditoría y recuperación de apps eliminadas accidentalmente
2026-01-19 19:40:47 -05:00

325 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Módulo para descubrir servicios systemd existentes
use std::fs;
use std::path::Path;
use crate::logger::get_logger;
use crate::config::{get_config_manager, MonitoredApp};
const SYSTEMD_DIR: &str = "/etc/systemd/system";
const SERVICE_PREFIX: &str = "siax-app-";
/// Descubre servicios systemd existentes con prefijo siax-app-*
pub fn discover_services() -> Vec<DiscoveredService> {
let logger = get_logger();
logger.info("Discovery", &format!("🔍 Escaneando servicios systemd en: {}", SYSTEMD_DIR));
println!("🔍 Discovery: Buscando servicios en {}", SYSTEMD_DIR);
let mut services = Vec::new();
// Leer directorio de systemd
let entries = match fs::read_dir(SYSTEMD_DIR) {
Ok(entries) => {
logger.info("Discovery", &format!("✅ Directorio {} accesible", SYSTEMD_DIR));
println!("✅ Discovery: Directorio {} accesible", SYSTEMD_DIR);
entries
},
Err(e) => {
logger.error("Discovery", &format!("❌ No se pudo leer directorio {}", SYSTEMD_DIR), Some(&e.to_string()));
println!("❌ Discovery: ERROR - No se pudo leer {}: {}", SYSTEMD_DIR, e);
return services;
}
};
// Buscar archivos siax-app-*.service
let mut total_files = 0;
let mut siax_files = 0;
for entry in entries.flatten() {
total_files += 1;
let path = entry.path();
if let Some(filename) = path.file_name() {
let filename_str = filename.to_string_lossy();
// Verificar que sea un archivo .service con nuestro prefijo
if filename_str.starts_with(SERVICE_PREFIX) && filename_str.ends_with(".service") {
siax_files += 1;
logger.info("Discovery", &format!("✅ Encontrado: {}", filename_str));
println!("✅ Discovery: Servicio detectado: {}", filename_str);
// Extraer nombre de la app
let app_name = extract_app_name(&filename_str);
// Leer configuración del servicio
if let Some(service) = parse_service_file(&path, &app_name) {
services.push(service);
} else {
logger.warning("Discovery", &format!("⚠️ No se pudo parsear: {}", filename_str), None);
println!("⚠️ Discovery: No se pudo parsear {}", filename_str);
}
}
}
}
logger.info("Discovery", &format!("📊 Escaneados {} archivos, {} con prefijo '{}', {} parseados exitosamente",
total_files, siax_files, SERVICE_PREFIX, services.len()));
println!("📊 Discovery: Archivos totales: {}, siax-app-*: {}, parseados: {}",
total_files, siax_files, services.len());
services
}
/// Extrae el nombre de la app desde el nombre del archivo
/// Ejemplo: "siax-app-IDEAS.service" -> "app_IDEAS"
fn extract_app_name(filename: &str) -> String {
// Remover "siax-app-" del inicio y ".service" del final
filename
.trim_start_matches(SERVICE_PREFIX)
.trim_end_matches(".service")
.to_string()
}
/// Servicio descubierto en systemd
#[derive(Debug, Clone)]
pub struct DiscoveredService {
pub app_name: String,
pub service_file: String,
pub working_directory: Option<String>,
pub user: Option<String>,
pub exec_start: Option<String>,
pub port: Option<i32>,
pub node_env: String,
pub entry_point: Option<String>,
pub node_bin: Option<String>,
}
/// Parsea un archivo .service para extraer configuración completa
fn parse_service_file(path: &Path, app_name: &str) -> Option<DiscoveredService> {
let logger = get_logger();
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
logger.error("Discovery", &format!("Error leyendo {}", path.display()), Some(&e.to_string()));
return None;
}
};
let mut service = DiscoveredService {
app_name: app_name.to_string(),
service_file: path.to_string_lossy().to_string(),
working_directory: None,
user: None,
exec_start: None,
port: None,
node_env: String::from("production"),
entry_point: None,
node_bin: None,
};
// Parsear líneas del archivo
for line in content.lines() {
let line = line.trim();
// WorkingDirectory
if line.starts_with("WorkingDirectory=") {
service.working_directory = Some(line.trim_start_matches("WorkingDirectory=").to_string());
}
// User
if line.starts_with("User=") {
service.user = Some(line.trim_start_matches("User=").to_string());
}
// ExecStart
if line.starts_with("ExecStart=") {
let exec_start = line.trim_start_matches("ExecStart=").to_string();
// Extraer node_bin y entry_point del ExecStart
// Ejemplo: /home/user/.nvm/versions/node/v24.12.0/bin/node server.js
let parts: Vec<&str> = exec_start.split_whitespace().collect();
if !parts.is_empty() {
service.node_bin = Some(parts[0].to_string());
// Buscar el archivo .js como entry_point
for part in &parts[1..] {
if part.ends_with(".js") {
service.entry_point = Some(part.to_string());
break;
}
}
}
service.exec_start = Some(exec_start);
}
// Environment con PORT
if line.starts_with("Environment=") && line.contains("PORT") {
if let Some(port) = extract_port_from_env(line) {
service.port = Some(port);
}
}
// Environment con NODE_ENV
if line.starts_with("Environment=") && line.contains("NODE_ENV") {
if let Some(env) = extract_env_value(line, "NODE_ENV") {
service.node_env = env;
}
}
}
logger.info("Discovery", &format!(" App: {}, User: {:?}, WorkDir: {:?}, Env: {}, EntryPoint: {:?}",
service.app_name,
service.user,
service.working_directory,
service.node_env,
service.entry_point
));
Some(service)
}
/// Extrae el puerto de una línea Environment
/// Ejemplo: Environment="PORT=3000" -> Some(3000)
fn extract_port_from_env(line: &str) -> Option<i32> {
// Buscar PORT=número
if let Some(start) = line.find("PORT=") {
let after_port = &line[start + 5..];
// Extraer números
let port_str: String = after_port.chars()
.take_while(|c| c.is_numeric())
.collect();
port_str.parse::<i32>().ok()
} else {
None
}
}
/// Extrae un valor de variable de entorno de una línea Environment
/// Ejemplo: Environment="NODE_ENV=production" -> Some("production")
fn extract_env_value(line: &str, var_name: &str) -> Option<String> {
let pattern = format!("{}=", var_name);
if let Some(start) = line.find(&pattern) {
let after_var = &line[start + pattern.len()..];
// Extraer hasta espacios, comillas o fin de línea
let value: String = after_var.chars()
.take_while(|c| !c.is_whitespace() && *c != '"')
.collect();
if !value.is_empty() {
Some(value)
} else {
None
}
} else {
None
}
}
/// Sincroniza los servicios descubiertos con monitored_apps.json
pub fn sync_discovered_services(services: Vec<DiscoveredService>) {
let logger = get_logger();
let config_manager = get_config_manager();
logger.info("Discovery", &format!("🔄 Sincronizando {} servicios descubiertos...", services.len()));
println!("🔄 Discovery: Sincronizando {} servicios con monitored_apps.json", services.len());
let mut added_count = 0;
let mut skipped_count = 0;
for service in services {
// Intentar detectar el puerto si no se encontró en Environment
let port = service.port.unwrap_or_else(|| {
detect_port_from_name(&service.app_name)
});
// Verificar si ya existe en la configuración
let existing_apps = config_manager.get_apps();
let already_exists = existing_apps.iter().any(|app| app.name == service.app_name);
if already_exists {
logger.info("Discovery", &format!("⏭️ {} ya existe en configuración", service.app_name));
println!("⏭️ Discovery: {} ya existe, omitiendo", service.app_name);
skipped_count += 1;
continue;
}
// Crear MonitoredApp con información completa
let service_name = format!("siax-app-{}.service", service.app_name);
let registered_at = chrono::Local::now().to_rfc3339();
let app = MonitoredApp {
name: service.app_name.clone(),
service_name,
path: service.working_directory.unwrap_or_default(),
port,
entry_point: service.entry_point.unwrap_or_default(),
node_bin: service.node_bin.unwrap_or_default(),
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,
};
// Agregar a monitored_apps.json
logger.info("Discovery", &format!(" Agregando {} (puerto: {}, entry: {})",
app.name, app.port, app.entry_point));
match config_manager.add_app_full(app) {
Ok(_) => {
logger.info("Discovery", &format!("{} agregado exitosamente", service.app_name));
println!("✅ Discovery: {} agregado a monitored_apps.json", service.app_name);
added_count += 1;
}
Err(e) => {
logger.error("Discovery", &format!("Error agregando {}", service.app_name), Some(&e));
println!("❌ Discovery: Error agregando {}: {}", service.app_name, e);
}
}
}
logger.info("Discovery", &format!("📊 Resumen: {} agregadas, {} ya existían", added_count, skipped_count));
println!("📊 Discovery: Resumen final - {} apps nuevas, {} existentes", added_count, skipped_count);
}
/// Intenta detectar el puerto desde el nombre de la app
/// Esto es un fallback simple si no se encuentra en el .service
fn detect_port_from_name(app_name: &str) -> i32 {
// Algunos puertos conocidos por nombre
match app_name.to_lowercase().as_str() {
name if name.contains("tareas") => 3000,
name if name.contains("fidelizacion") => 3001,
name if name.contains("ideas") => 2000,
_ => 8080, // Puerto por defecto genérico
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_app_name() {
assert_eq!(extract_app_name("siax-app-IDEAS.service"), "IDEAS");
assert_eq!(extract_app_name("siax-app-TAREAS.service"), "TAREAS");
assert_eq!(extract_app_name("siax-app-fidelizacion.service"), "fidelizacion");
}
#[test]
fn test_extract_port_from_env() {
assert_eq!(extract_port_from_env("Environment=PORT=3000"), Some(3000));
assert_eq!(extract_port_from_env("Environment=\"PORT=8080\""), Some(8080));
assert_eq!(extract_port_from_env("Environment=NODE_ENV=production"), None);
}
#[test]
fn test_detect_port_from_name() {
assert_eq!(detect_port_from_name("app_tareas"), 3000);
assert_eq!(detect_port_from_name("IDEAS"), 2000);
assert_eq!(detect_port_from_name("unknown_app"), 8080);
}
}