feat: Implementación completa Fase 4 - Sistema de monitoreo con API REST y WebSocket
✨ Nuevas funcionalidades: - API REST unificada en puerto 8080 (eliminado CORS) - WebSocket para logs en tiempo real desde journalctl - Integración completa con systemd para gestión de servicios - Escaneo automático de procesos Node.js y Python - Rate limiting (1 operación/segundo por app) - Interface web moderna con Tailwind CSS (tema oscuro) - Documentación API estilo Swagger completamente en español 🎨 Interface Web (todas las páginas en español): - Dashboard con estadísticas en tiempo real - Visor de escaneo de procesos con filtros - Formulario de registro de aplicaciones con variables de entorno - Visor de logs en tiempo real con WebSocket y sidebar - Página de selección de apps detectadas - Documentación completa de API REST 🏗️ Arquitectura: - Módulo models: ServiceConfig, ManagedApp, AppStatus - Módulo systemd: wrapper de systemctl, generador de .service, parser - Módulo orchestrator: AppManager, LifecycleManager con validaciones - Módulo api: handlers REST, WebSocket manager, DTOs - Servidor unificado en puerto 8080 (Web + API + WS) 🔧 Mejoras técnicas: - Eliminación de CORS mediante servidor unificado - Separación clara frontend/backend con carga dinámica - Thread-safe con Arc<DashMap> para estado compartido - Reconciliación de estados: sysinfo vs systemd - Validaciones de paths, usuarios y configuraciones - Manejo robusto de errores con thiserror 📝 Documentación: - README.md actualizado con arquitectura completa - EJEMPLOS.md con casos de uso detallados - ESTADO_PROYECTO.md con progreso de Fase 4 - API docs interactiva en /api-docs - Script de despliegue mejorado con health checks 🚀 Producción: - Deployment script con validaciones - Health checks y rollback capability - Configuración de sudoers para systemctl - Hardening de seguridad en servicios systemd
This commit is contained in:
86
src/api/dto.rs
Normal file
86
src/api/dto.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RegisterAppRequest {
|
||||
pub app_name: String,
|
||||
pub script_path: String,
|
||||
pub working_directory: String,
|
||||
pub user: String,
|
||||
#[serde(default)]
|
||||
pub environment: HashMap<String, String>,
|
||||
#[serde(default = "default_restart_policy")]
|
||||
pub restart_policy: String,
|
||||
pub app_type: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
fn default_restart_policy() -> String {
|
||||
"always".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> ApiResponse<T> {
|
||||
pub fn success(data: T) -> Self {
|
||||
ApiResponse {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(error: String) -> Self {
|
||||
ApiResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppStatusResponse {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub pid: Option<i32>,
|
||||
pub cpu_usage: f32,
|
||||
pub memory_usage: String,
|
||||
pub systemd_status: String,
|
||||
pub last_updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppListResponse {
|
||||
pub apps: Vec<String>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OperationResponse {
|
||||
pub app_name: String,
|
||||
pub operation: String,
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DetectedProcess {
|
||||
pub pid: i32,
|
||||
pub name: String,
|
||||
pub user: Option<String>,
|
||||
pub cpu_usage: f64,
|
||||
pub memory_mb: f64,
|
||||
pub process_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProcessScanResponse {
|
||||
pub processes: Vec<DetectedProcess>,
|
||||
pub total: usize,
|
||||
}
|
||||
195
src/api/handlers.rs
Normal file
195
src/api/handlers.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use sysinfo::System;
|
||||
use crate::orchestrator::{AppManager, LifecycleManager};
|
||||
use crate::models::{ServiceConfig, RestartPolicy, AppType};
|
||||
use super::dto::*;
|
||||
|
||||
pub struct ApiState {
|
||||
pub app_manager: Arc<AppManager>,
|
||||
pub lifecycle_manager: Arc<LifecycleManager>,
|
||||
}
|
||||
|
||||
pub async fn register_app_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(payload): Json<RegisterAppRequest>,
|
||||
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
match state.app_manager.register_app(config) {
|
||||
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
|
||||
app_name: payload.app_name,
|
||||
operation: "register".to_string(),
|
||||
success: true,
|
||||
message: "Aplicación registrada exitosamente".to_string(),
|
||||
}))),
|
||||
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unregister_app_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(app_name): Path<String>,
|
||||
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
|
||||
|
||||
match state.app_manager.unregister_app(&app_name) {
|
||||
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
|
||||
app_name: app_name.clone(),
|
||||
operation: "unregister".to_string(),
|
||||
success: true,
|
||||
message: "Aplicación eliminada exitosamente".to_string(),
|
||||
}))),
|
||||
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_app_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(app_name): Path<String>,
|
||||
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
|
||||
|
||||
match state.lifecycle_manager.start_app(&app_name) {
|
||||
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
|
||||
app_name: app_name.clone(),
|
||||
operation: "start".to_string(),
|
||||
success: true,
|
||||
message: "Aplicación iniciada exitosamente".to_string(),
|
||||
}))),
|
||||
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_app_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(app_name): Path<String>,
|
||||
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
|
||||
|
||||
match state.lifecycle_manager.stop_app(&app_name) {
|
||||
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
|
||||
app_name: app_name.clone(),
|
||||
operation: "stop".to_string(),
|
||||
success: true,
|
||||
message: "Aplicación detenida exitosamente".to_string(),
|
||||
}))),
|
||||
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restart_app_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(app_name): Path<String>,
|
||||
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
|
||||
|
||||
match state.lifecycle_manager.restart_app(&app_name) {
|
||||
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
|
||||
app_name: app_name.clone(),
|
||||
operation: "restart".to_string(),
|
||||
success: true,
|
||||
message: "Aplicación reiniciada exitosamente".to_string(),
|
||||
}))),
|
||||
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_app_status_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(app_name): Path<String>,
|
||||
) -> Result<Json<ApiResponse<AppStatusResponse>>, StatusCode> {
|
||||
|
||||
match state.app_manager.get_app_status(&app_name) {
|
||||
Some(managed_app) => {
|
||||
let response = AppStatusResponse {
|
||||
name: managed_app.name,
|
||||
status: managed_app.status.as_str().to_string(),
|
||||
pid: managed_app.pid,
|
||||
cpu_usage: managed_app.cpu_usage,
|
||||
memory_usage: format!("{:.2} MB", managed_app.memory_usage as f64 / 1024.0 / 1024.0),
|
||||
systemd_status: managed_app.systemd_status.as_str().to_string(),
|
||||
last_updated: managed_app.last_updated,
|
||||
};
|
||||
Ok(Json(ApiResponse::success(response)))
|
||||
}
|
||||
None => Ok(Json(ApiResponse::error(
|
||||
format!("Aplicación '{}' no encontrada", app_name)
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_apps_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
) -> Result<Json<ApiResponse<AppListResponse>>, StatusCode> {
|
||||
|
||||
let apps = state.app_manager.list_apps();
|
||||
let total = apps.len();
|
||||
|
||||
Ok(Json(ApiResponse::success(AppListResponse { apps, total })))
|
||||
}
|
||||
|
||||
pub async fn scan_processes_handler() -> Result<Json<ApiResponse<ProcessScanResponse>>, StatusCode> {
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
let mut detected_processes = Vec::new();
|
||||
|
||||
for (pid, process) in sys.processes() {
|
||||
let process_name = process.name().to_lowercase();
|
||||
let cmd = process.cmd().join(" ").to_lowercase();
|
||||
|
||||
let process_type = if process_name.contains("node") || cmd.contains("node") {
|
||||
Some("nodejs")
|
||||
} else if process_name.contains("python") || cmd.contains("python") {
|
||||
Some("python")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ptype) = process_type {
|
||||
detected_processes.push(DetectedProcess {
|
||||
pid: pid.as_u32() as i32,
|
||||
name: process.name().to_string(),
|
||||
user: process.user_id().map(|u| u.to_string()),
|
||||
cpu_usage: process.cpu_usage() as f64,
|
||||
memory_mb: process.memory() as f64 / 1024.0 / 1024.0,
|
||||
process_type: ptype.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total = detected_processes.len();
|
||||
|
||||
Ok(Json(ApiResponse::success(ProcessScanResponse {
|
||||
processes: detected_processes,
|
||||
total,
|
||||
})))
|
||||
}
|
||||
7
src/api/mod.rs
Normal file
7
src/api/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod handlers;
|
||||
pub mod websocket;
|
||||
pub mod dto;
|
||||
|
||||
pub use handlers::*;
|
||||
pub use websocket::*;
|
||||
pub use dto::*;
|
||||
188
src/api/websocket.rs
Normal file
188
src/api/websocket.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
Path, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::{sink::SinkExt, stream::StreamExt};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use crate::logger::get_logger;
|
||||
use dashmap::DashMap;
|
||||
|
||||
pub struct WebSocketManager {
|
||||
active_connections: Arc<DashMap<String, usize>>,
|
||||
max_connections_per_app: usize,
|
||||
}
|
||||
|
||||
impl WebSocketManager {
|
||||
pub fn new() -> Self {
|
||||
WebSocketManager {
|
||||
active_connections: Arc::new(DashMap::new()),
|
||||
max_connections_per_app: 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_connect(&self, app_name: &str) -> bool {
|
||||
let count = self.active_connections
|
||||
.get(app_name)
|
||||
.map(|v| *v)
|
||||
.unwrap_or(0);
|
||||
|
||||
count < self.max_connections_per_app
|
||||
}
|
||||
|
||||
fn increment_connection(&self, app_name: &str) {
|
||||
self.active_connections
|
||||
.entry(app_name.to_string())
|
||||
.and_modify(|c| *c += 1)
|
||||
.or_insert(1);
|
||||
}
|
||||
|
||||
fn decrement_connection(&self, app_name: &str) {
|
||||
self.active_connections
|
||||
.entry(app_name.to_string())
|
||||
.and_modify(|c| {
|
||||
if *c > 0 {
|
||||
*c -= 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebSocketManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logs_websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Path(app_name): Path<String>,
|
||||
State(ws_manager): State<Arc<WebSocketManager>>,
|
||||
) -> impl IntoResponse {
|
||||
let logger = get_logger();
|
||||
|
||||
// Verificar límite de conexiones
|
||||
if !ws_manager.can_connect(&app_name) {
|
||||
logger.warning(
|
||||
"WebSocket",
|
||||
"Límite de conexiones excedido",
|
||||
Some(&app_name)
|
||||
);
|
||||
return ws.on_upgrade(move |socket| async move {
|
||||
let mut socket = socket;
|
||||
let _ = socket.send(Message::Text(
|
||||
"Error: Límite de conexiones simultáneas excedido".to_string()
|
||||
)).await;
|
||||
let _ = socket.close().await;
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("WebSocket", &format!("Nueva conexión para logs de: {}", app_name));
|
||||
ws_manager.increment_connection(&app_name);
|
||||
|
||||
ws.on_upgrade(move |socket| handle_logs_socket(socket, app_name, ws_manager))
|
||||
}
|
||||
|
||||
async fn handle_logs_socket(
|
||||
socket: WebSocket,
|
||||
app_name: String,
|
||||
ws_manager: Arc<WebSocketManager>,
|
||||
) {
|
||||
let logger = get_logger();
|
||||
let service_name = format!("{}.service", app_name);
|
||||
|
||||
// Iniciar journalctl
|
||||
let mut child = match TokioCommand::new("journalctl")
|
||||
.arg("-u")
|
||||
.arg(&service_name)
|
||||
.arg("-f")
|
||||
.arg("--output=json")
|
||||
.arg("-n")
|
||||
.arg("50") // Últimas 50 líneas
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
logger.error("WebSocket", "Error iniciando journalctl", Some(&e.to_string()));
|
||||
ws_manager.decrement_connection(&app_name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Enviar mensaje de bienvenida
|
||||
let welcome = format!("📡 Conectado a logs de: {}", app_name);
|
||||
let _ = sender.send(Message::Text(welcome)).await;
|
||||
|
||||
// Task para enviar logs
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
// Parsear JSON de journalctl
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
let message = json.get("MESSAGE")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&line);
|
||||
|
||||
let timestamp = json.get("__REALTIME_TIMESTAMP")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let formatted = if !timestamp.is_empty() {
|
||||
format!("[{}] {}", timestamp, message)
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
|
||||
if sender.send(Message::Text(formatted)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Si no es JSON, enviar la línea tal cual
|
||||
if sender.send(Message::Text(line)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Task para recibir mensajes del cliente (para detectar desconexión)
|
||||
let receive_task = tokio::spawn(async move {
|
||||
while let Some(msg) = receiver.next().await {
|
||||
if let Ok(msg) = msg {
|
||||
match msg {
|
||||
Message::Close(_) => break,
|
||||
Message::Ping(_) => {
|
||||
// Los pings se manejan automáticamente
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Esperar a que termine alguna de las dos tasks
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = receive_task => {},
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = child.kill().await;
|
||||
ws_manager.decrement_connection(&app_name);
|
||||
|
||||
logger.info("WebSocket", &format!("Conexión cerrada para: {}", app_name));
|
||||
}
|
||||
Reference in New Issue
Block a user