feat: Sistema de monitoreo base con logging y configuración dinámica

- Implementado monitor de procesos Node.js con detección automática
- Sistema de logging con niveles (Info, Warning, Error, Critical)
- ConfigManager para gestión dinámica de apps monitoreadas
- Interfaz web básica con escaneo de procesos
- Integración con API central para reportar estados
- User-Agent tracking para identificación de agentes
- Persistencia de configuración en JSON
- Logs almacenados en archivo con rotación
- Sistema modular: monitor, interface, logger, config

Estructura:
- src/main.rs: Orquestador principal
- src/monitor.rs: Monitoreo de procesos y envío a API
- src/interface.rs: Servidor web Axum con endpoints
- src/logger.rs: Sistema de logging a archivo y consola
- src/config.rs: Gestión de configuración persistente
- web/: Templates HTML para interfaz web
- config/: Configuración de apps monitoreadas
- logs/: Archivos de log del sistema

Features implementadas:
 Detección automática de procesos Node.js
 Monitoreo de CPU y RAM por proceso
 Reportes periódicos a API central (cada 60s)
 Interfaz web en puerto 8080
 Logs estructurados con timestamps
 Configuración dinámica sin reinicio
 Script de despliegue automatizado

Próximos pasos:
- Integración con systemd para control de procesos
- Dashboard mejorado con cards de apps
- Logs en tiempo real vía WebSocket
- Start/Stop/Restart de aplicaciones
This commit is contained in:
2026-01-11 23:14:09 -05:00
parent bc1953fce1
commit 3595e55a1e
20 changed files with 3465 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
use serde::{Serialize, Deserialize};
use std::fs::{self, create_dir_all};
use std::path::Path;
use std::sync::{Arc, RwLock, OnceLock};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitoredApp {
pub name: String,
pub port: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub apps: Vec<MonitoredApp>,
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
apps: vec![
MonitoredApp { name: "app_tareas".to_string(), port: 3000 },
MonitoredApp { name: "fidelizacion".to_string(), port: 3001 },
]
}
}
}
pub struct ConfigManager {
config_path: String,
config: Arc<RwLock<AppConfig>>,
}
impl ConfigManager {
pub fn new(config_path: &str) -> Self {
// Crear directorio config si no existe
if let Some(parent) = Path::new(config_path).parent() {
let _ = create_dir_all(parent);
}
// Cargar o crear configuración
let config = Self::load_config(config_path);
ConfigManager {
config_path: config_path.to_string(),
config: Arc::new(RwLock::new(config)),
}
}
fn load_config(path: &str) -> AppConfig {
match fs::read_to_string(path) {
Ok(content) => {
match serde_json::from_str(&content) {
Ok(config) => {
println!("✅ Configuración cargada desde: {}", path);
config
}
Err(e) => {
eprintln!("⚠️ Error parseando config: {}. Usando default.", e);
AppConfig::default()
}
}
}
Err(_) => {
println!(" Archivo de config no encontrado. Creando uno nuevo...");
let default_config = AppConfig::default();
let _ = Self::save_config_to_file(path, &default_config);
default_config
}
}
}
fn save_config_to_file(path: &str, config: &AppConfig) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(config)?;
fs::write(path, json)?;
println!("💾 Configuración guardada en: {}", path);
Ok(())
}
pub fn get_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap();
config.apps.clone()
}
pub fn add_app(&self, name: String, port: i32) -> Result<(), String> {
let mut config = self.config.write().unwrap();
// Verificar si ya existe
if config.apps.iter().any(|app| app.name == name) {
return Err(format!("La app '{}' ya está siendo monitoreada", name));
}
config.apps.push(MonitoredApp { name, port });
// 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))
}
}
pub fn remove_app(&self, name: &str) -> Result<(), String> {
let mut config = self.config.write().unwrap();
let original_len = config.apps.len();
config.apps.retain(|app| app.name != name);
if config.apps.len() == original_len {
return Err(format!("La app '{}' no se encontró", name));
}
// 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))
}
}
pub fn get_arc(&self) -> Arc<RwLock<AppConfig>> {
Arc::clone(&self.config)
}
}
// Singleton global del ConfigManager
static CONFIG_MANAGER: OnceLock<ConfigManager> = OnceLock::new();
// ⚠️ IMPORTANTE: Esta función DEBE ser pública
pub fn get_config_manager() -> &'static ConfigManager {
CONFIG_MANAGER.get_or_init(|| ConfigManager::new("config/monitored_apps.json"))
}

View File

@@ -0,0 +1,260 @@
use axum::{
routing::{get, post},
response::Html,
Router,
extract::Form,
};
use std::net::SocketAddr;
use sysinfo::System;
use serde::Deserialize;
use crate::logger::{get_logger, LogLevel};
#[derive(Deserialize)]
struct ProcessForm {
app_name: String,
port: String,
}
pub async fn start_web_server(port: u16) {
let app = Router::new()
.route("/", get(index_handler))
.route("/scan", get(scan_processes_handler))
.route("/select", get(select_processes_handler))
.route("/add-process", post(add_process_handler))
.route("/logs", get(logs_handler))
.route("/clear-logs", post(clear_logs_handler));
let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("🖥️ Interface Web en: http://localhost:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn index_handler() -> Html<String> {
let template = include_str!("../web/index.html");
let html = template.replace("{{SERVER_NAME}}", "siax-intel");
Html(html)
}
async fn scan_processes_handler() -> Html<String> {
let mut sys = System::new_all();
sys.refresh_all();
let template = include_str!("../web/scan.html");
let mut content = String::new();
let mut node_count = 0;
for (pid, process) in sys.processes() {
let process_name = process.name();
if process_name.contains("node") {
node_count += 1;
let cpu = process.cpu_usage();
let mem_mb = process.memory() as f64 / 1024.0 / 1024.0;
let cwd = if let Some(path) = process.cwd() {
path.to_string_lossy().to_string()
} else {
"N/A".to_string()
};
content.push_str(&format!(
r#"
<div class="process">
<div><span class="pid">PID: {}</span> | <span class="name">{}</span></div>
<div><span class="cpu">CPU: {:.2}%</span> | <span class="mem">RAM: {:.2} MB</span></div>
<div class="path">📁 {}</div>
</div>
"#,
pid.as_u32(),
process_name,
cpu,
mem_mb,
cwd
));
}
}
if node_count == 0 {
content = r#"<div class="no-results">⚠️ No se detectaron procesos Node.js en ejecución</div>"#.to_string();
} else {
let summary = format!(r#"<p class="summary">✅ Total: {} proceso(s) Node.js detectado(s)</p>"#, node_count);
content = summary + &content;
}
let html = template.replace("{{CONTENT}}", &content);
Html(html)
}
async fn select_processes_handler() -> Html<String> {
let mut sys = System::new_all();
sys.refresh_all();
let template = include_str!("../web/select.html");
let mut processes_list = String::new();
let mut node_processes = Vec::new();
for (pid, process) in sys.processes() {
let process_name = process.name();
if process_name.contains("node") {
let cwd = if let Some(path) = process.cwd() {
path.to_string_lossy().to_string()
} else {
"N/A".to_string()
};
node_processes.push((pid.as_u32(), process_name.to_string(), cwd));
}
}
if node_processes.is_empty() {
processes_list = r#"<div class="no-results">⚠️ No se detectaron procesos Node.js en ejecución</div>"#.to_string();
} else {
for (pid, name, cwd) in node_processes {
let suggested_name = cwd.split(&['/', '\\'][..])
.filter(|s| !s.is_empty())
.last()
.unwrap_or("app");
processes_list.push_str(&format!(
r#"
<div class="process-item">
<div class="process-info">
<div><span class="pid">PID: {}</span> | {}</div>
<div class="path">📁 {}</div>
</div>
<button class="select-btn" onclick="fillForm('{}', {})">
✅ Seleccionar
</button>
</div>
"#,
pid, name, cwd, suggested_name, pid
));
}
}
let html = template.replace("{{PROCESSES_LIST}}", &processes_list);
Html(html)
}
async fn add_process_handler(Form(form): Form<ProcessForm>) -> Html<String> {
let logger = get_logger();
let template = include_str!("../web/success.html");
let port: i32 = form.port.parse().unwrap_or(0);
logger.info("Interface", &format!("Nuevo proceso agregado: {} en puerto {}", form.app_name, port));
let html = template
.replace("{{APP_NAME}}", &form.app_name)
.replace("{{PORT}}", &port.to_string());
Html(html)
}
async fn logs_handler() -> Html<String> {
let logger = get_logger();
let template = include_str!("../web/logs.html");
let logs = logger.read_logs(Some(100)); // Últimos 100 logs
// Calcular estadísticas
let mut info_count = 0;
let mut warning_count = 0;
let mut error_count = 0;
let mut critical_count = 0;
for log in &logs {
match log.level {
LogLevel::Info => info_count += 1,
LogLevel::Warning => warning_count += 1,
LogLevel::Error => error_count += 1,
LogLevel::Critical => critical_count += 1,
}
}
let stats = format!(
r#"
<div class="stat-item stat-info">
<div class="stat-number" style="color: #3b82f6;">{}</div>
<div class="stat-label">Info</div>
</div>
<div class="stat-item stat-warning">
<div class="stat-number" style="color: #f59e0b;">{}</div>
<div class="stat-label">Warnings</div>
</div>
<div class="stat-item stat-error">
<div class="stat-number" style="color: #ef4444;">{}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-item stat-critical">
<div class="stat-number" style="color: #dc2626;">{}</div>
<div class="stat-label">Critical</div>
</div>
"#,
info_count, warning_count, error_count, critical_count
);
let mut logs_html = String::new();
if logs.is_empty() {
logs_html = r#"<div class="no-logs">📭 No hay logs registrados</div>"#.to_string();
} else {
for log in logs {
let level_class = match log.level {
LogLevel::Info => "info",
LogLevel::Warning => "warning",
LogLevel::Error => "error",
LogLevel::Critical => "critical",
};
let details_html = if let Some(details) = &log.details {
format!(r#"<div class="log-details">📝 {}</div>"#, details)
} else {
String::new()
};
logs_html.push_str(&format!(
r#"
<div class="log-entry log-{}" data-level="{}">
<div class="log-header">
<span class="log-module">[{}]</span>
<span class="log-timestamp">{}</span>
</div>
<div class="log-message">{} {}</div>
{}
</div>
"#,
level_class,
level_class,
log.module,
log.timestamp,
log.level.emoji(),
log.message,
details_html
));
}
}
let html = template
.replace("{{STATS}}", &stats)
.replace("{{LOGS}}", &logs_html);
Html(html)
}
async fn clear_logs_handler() -> Html<&'static str> {
let logger = get_logger();
match logger.clear_logs() {
Ok(_) => {
logger.info("Interface", "Logs limpiados por el usuario");
Html("OK")
}
Err(e) => {
logger.error("Interface", "Error al limpiar logs", Some(&e.to_string()));
Html("ERROR")
}
}
}

View File

@@ -0,0 +1,237 @@
use std::fs::{OpenOptions, create_dir_all};
use std::io::Write;
use std::path::Path;
use chrono::Local;
use serde::{Serialize, Deserialize};
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: LogLevel,
pub module: String,
pub message: String,
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Info,
Warning,
Error,
Critical,
}
impl LogLevel {
pub fn as_str(&self) -> &str {
match self {
LogLevel::Info => "INFO",
LogLevel::Warning => "WARNING",
LogLevel::Error => "ERROR",
LogLevel::Critical => "CRITICAL",
}
}
pub fn color(&self) -> &str {
match self {
LogLevel::Info => "#3b82f6",
LogLevel::Warning => "#f59e0b",
LogLevel::Error => "#ef4444",
LogLevel::Critical => "#dc2626",
}
}
pub fn emoji(&self) -> &str {
match self {
LogLevel::Info => "",
LogLevel::Warning => "⚠️",
LogLevel::Error => "",
LogLevel::Critical => "🔥",
}
}
}
pub struct Logger {
log_file: String,
}
impl Logger {
pub fn new() -> Self {
let log_file = "logs/errors.log".to_string();
// Crear directorio logs si no existe
if let Some(parent) = Path::new(&log_file).parent() {
let _ = create_dir_all(parent);
}
Logger { log_file }
}
pub fn log(&self, level: LogLevel, module: &str, message: &str, details: Option<&str>) {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let entry = LogEntry {
timestamp: timestamp.clone(),
level: level.clone(),
module: module.to_string(),
message: message.to_string(),
details: details.map(|d| d.to_string()),
};
// Escribir en archivo
if let Err(e) = self.write_to_file(&entry) {
eprintln!("Error escribiendo log: {}", e);
}
// También imprimir en consola
self.print_to_console(&entry);
}
fn write_to_file(&self, entry: &LogEntry) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_file)?;
let log_line = if let Some(details) = &entry.details {
format!(
"[{}] [{}] [{}] {} | {}\n",
entry.timestamp,
entry.level.as_str(),
entry.module,
entry.message,
details
)
} else {
format!(
"[{}] [{}] [{}] {}\n",
entry.timestamp,
entry.level.as_str(),
entry.module,
entry.message
)
};
file.write_all(log_line.as_bytes())?;
Ok(())
}
fn print_to_console(&self, entry: &LogEntry) {
let emoji = entry.level.emoji();
let level = entry.level.as_str();
if let Some(details) = &entry.details {
println!(
"{} [{}] [{}] {} | {}",
emoji, level, entry.module, entry.message, details
);
} else {
println!(
"{} [{}] [{}] {}",
emoji, level, entry.module, entry.message
);
}
}
pub fn read_logs(&self, limit: Option<usize>) -> Vec<LogEntry> {
use std::fs::File;
use std::io::{BufRead, BufReader};
let mut entries = Vec::new();
if let Ok(file) = File::open(&self.log_file) {
let reader = BufReader::new(file);
for line in reader.lines() {
if let Ok(line_content) = line {
if let Some(entry) = self.parse_log_line(&line_content) {
entries.push(entry);
}
}
}
}
// Invertir para mostrar los más recientes primero
entries.reverse();
// Limitar cantidad si se especifica
if let Some(limit) = limit {
entries.truncate(limit);
}
entries
}
fn parse_log_line(&self, line: &str) -> Option<LogEntry> {
// Formato: [timestamp] [level] [module] message | details
let parts: Vec<&str> = line.split("] [").collect();
if parts.len() < 3 {
return None;
}
let timestamp = parts[0].trim_start_matches('[').to_string();
let level_str = parts[1];
let module = parts[2].to_string();
let level = match level_str {
"INFO" => LogLevel::Info,
"WARNING" => LogLevel::Warning,
"ERROR" => LogLevel::Error,
"CRITICAL" => LogLevel::Critical,
_ => LogLevel::Info,
};
// El resto es el mensaje y detalles
let rest = parts.get(3..)?.join("] [");
let rest = rest.trim_end_matches(']');
let (message, details) = if let Some(idx) = rest.find(" | ") {
let msg = rest[..idx].to_string();
let det = rest[idx + 3..].to_string();
(msg, Some(det))
} else {
(rest.to_string(), None)
};
Some(LogEntry {
timestamp,
level,
module,
message,
details,
})
}
pub fn clear_logs(&self) -> std::io::Result<()> {
use std::fs;
fs::write(&self.log_file, "")?;
Ok(())
}
// Métodos de conveniencia
pub fn info(&self, module: &str, message: &str) {
self.log(LogLevel::Info, module, message, None);
}
pub fn warning(&self, module: &str, message: &str, details: Option<&str>) {
self.log(LogLevel::Warning, module, message, details);
}
pub fn error(&self, module: &str, message: &str, details: Option<&str>) {
self.log(LogLevel::Error, module, message, details);
}
pub fn critical(&self, module: &str, message: &str, details: Option<&str>) {
self.log(LogLevel::Critical, module, message, details);
}
}
// Singleton global del logger
static LOGGER: OnceLock<Logger> = OnceLock::new();
// ⚠️ IMPORTANTE: Esta función DEBE ser pública
pub fn get_logger() -> &'static Logger {
LOGGER.get_or_init(|| Logger::new())
}

39
src/main.rs Normal file
View File

@@ -0,0 +1,39 @@
mod monitor;
mod interface;
mod logger;
mod config;
use logger::get_logger;
use config::get_config_manager;
#[tokio::main]
async fn main() {
// Inicializar logger
let logger = get_logger();
logger.info("Sistema", "Iniciando SIAX Agent");
// Inicializar config manager
let config_manager = get_config_manager();
let apps = config_manager.get_apps();
println!("📋 Apps a monitorear: {:?}", apps);
let server_name = "siax-intel".to_string();
let api_key = "ak_VVeNzGxK2mCq8s7YpFtHjL3b9dR4TuZ6".to_string();
let cloud_url = "https://api.siax-system.net/api/apps_servcs/apps".to_string();
// 1. Iniciamos el Monitor
let monitor_handle = tokio::spawn(async move {
monitor::run_monitoring(server_name, api_key, cloud_url).await;
});
// 2. Iniciamos la Interface Web
let web_handle = tokio::spawn(async move {
interface::start_web_server(8080).await;
});
println!("✅ Sistema SIAX operativo. Monitor en segundo plano e Interface en puerto 8080.");
logger.info("Sistema", "Sistema SIAX completamente operativo");
// Esperamos a ambos
let _ = tokio::join!(monitor_handle, web_handle);
}

View File

@@ -0,0 +1,160 @@
use sysinfo::System;
use serde::Serialize;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use std::time::Duration;
use crate::logger::get_logger;
use crate::config::get_config_manager;
// User-Agent dinámico
fn generate_user_agent() -> String {
let version = env!("CARGO_PKG_VERSION");
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
format!("SIAX-Agent/{} ({}/{}; Rust-Monitor)", version, os, arch)
}
#[derive(Serialize, Debug)]
struct AppStatusUpdate {
app_name: String,
server: String,
status: String,
port: i32,
pid: i32,
memory_usage: String,
cpu_usage: String,
last_check: String,
}
pub async fn run_monitoring(server_name: String, api_key: String, cloud_url: String) {
let logger = get_logger();
let config_manager = get_config_manager();
let mut sys = System::new_all();
let user_agent = generate_user_agent();
logger.info("Monitor", &format!("Vigilando procesos para {} [{}]", server_name, user_agent));
println!("🚀 Monitor: Vigilando procesos para {}", server_name);
println!("📡 User-Agent: {}", user_agent);
loop {
sys.refresh_all();
// ✨ LEER APPS DESDE CONFIG (dinámico)
let apps_to_monitor = config_manager.get_apps();
if apps_to_monitor.is_empty() {
logger.warning("Monitor", "No hay apps configuradas para monitorear", None);
}
for app in apps_to_monitor {
let data = collect_metrics(&sys, &app.name, app.port, &server_name);
match send_to_cloud(data, &api_key, &cloud_url, &user_agent).await {
Ok(_) => {},
Err(e) => {
logger.error(
"Monitor",
&format!("Error enviando {}", app.name),
Some(&e.to_string())
);
eprintln!("❌ Error enviando {}: {}", app.name, e);
}
}
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
fn collect_metrics(sys: &System, name: &str, port: i32, server: &str) -> AppStatusUpdate {
let mut pid_encontrado = 0;
let mut cpu = 0.0;
let mut mem = 0.0;
let mut status = "stopped".to_string();
for (pid, process) in sys.processes() {
let process_name = process.name();
if process_name.contains("node") {
if let Some(cwd) = process.cwd() {
let cwd_str = cwd.to_string_lossy();
if cwd_str.contains(name) {
pid_encontrado = pid.as_u32() as i32;
cpu = process.cpu_usage();
mem = process.memory() as f64 / 1024.0 / 1024.0;
status = "running".to_string();
break;
}
}
}
}
let now = chrono::Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
AppStatusUpdate {
app_name: name.to_string(),
server: server.to_string(),
status,
port,
pid: pid_encontrado,
memory_usage: format!("{:.2}MB", mem),
cpu_usage: format!("{:.2}%", cpu),
last_check: timestamp,
}
}
async fn send_to_cloud(
data: AppStatusUpdate,
api_key: &str,
cloud_url: &str,
user_agent: &str
) -> Result<(), Box<dyn std::error::Error>> {
let logger = get_logger();
let client = reqwest::Client::new();
let mut headers = HeaderMap::new();
headers.insert(
"x-api-key",
HeaderValue::from_str(api_key)?
);
headers.insert(
"Content-Type",
HeaderValue::from_static("application/json")
);
headers.insert(
USER_AGENT,
HeaderValue::from_str(user_agent)?
);
let response = client
.post(cloud_url)
.headers(headers)
.json(&data)
.timeout(Duration::from_secs(10))
.send()
.await?;
if response.status().is_success() {
println!("📤 {} -> {} (PID: {}, CPU: {}, RAM: {})",
data.app_name,
data.status,
data.pid,
data.cpu_usage,
data.memory_usage
);
Ok(())
} else {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Sin respuesta".to_string());
logger.error(
"Monitor",
&format!("Error enviando datos de {}", data.app_name),
Some(&format!("HTTP {}: {}", status, error_text))
);
eprintln!("⚠️ Error HTTP {}: {}", status, error_text);
Err(format!("HTTP {}: {}", status, error_text).into())
}
}