Compare commits

...

14 Commits

Author SHA1 Message Date
058e4781e6 feat: Sistema de variables de entorno mejorado con EnvironmentFile
- Agregado campo 'environment' a MonitoredApp para almacenar variables ADICIONALES
- Solo se almacenan en JSON las variables agregadas manualmente desde el panel
- Las variables del .env del proyecto se cargan automáticamente con EnvironmentFile
- Modificado service_generator.rs para usar directiva EnvironmentFile en systemd
- Fix: Usuario ahora se lee correctamente del JSON sin fallback a 'root'
- Edit.html pre-carga variables adicionales del JSON al editar
- Separación clara: .env (proyecto) vs variables adicionales (JSON)
- Transparencia total con .env nativo del proyecto

Beneficios:
 No duplicación de variables (.env es la fuente de verdad)
 JSON solo guarda variables extras (pequeño y limpio)
 .env funciona igual que en desarrollo
 systemd lee .env con EnvironmentFile
 Variables adicionales se persisten en JSON
 Al editar, se pre-cargan variables adicionales guardadas
2026-01-21 21:48:59 -05:00
93d178b216 docs: Agregar Fase 5 - Script de inicialización de .env
- Nueva sección: Fase 5.1 - Script de Inicialización de .env
- Documentado el problema del .env en .gitignore
- Propuesta de solución: sync_env.sh desde servidor central
- 3 opciones: API Central, SCP, Vault/Secrets Manager
- Incluye script de ejemplo con curl + validación
- Fase 5.2: Templates de .env
- Fase 5.3: Gestión centralizada de secrets
- Actualizadas métricas del proyecto (25+ commits, 15 endpoints)
- Documentadas nuevas features implementadas
2026-01-21 20:59:51 -05:00
cd14cc5c06 feat: Agregar campo 'user' al JSON de configuración
- Agregado campo 'user: String' a MonitoredApp
- Función default_user() que obtiene usuario del sistema ($USER/$LOGNAME)
- Actualizado todos los lugares donde se crea MonitoredApp:
  * config.rs (add_app)
  * app_manager.rs (register_app) - usa config.user
  * discovery.rs (sync) - extrae de .service file
  * handlers.rs (update_app) - usa config.user
- El campo se guarda en monitored_apps.json
- Formulario de edición ahora carga el usuario correcto
- Resuelve problema: antes ponía 'pablinux' por defecto
- Ahora muestra el usuario real (ej: 'user_apps')
2026-01-21 18:08:40 -05:00
bb25004e67 fix: Cargar correctamente datos de la app en formulario de edición
- Construir script_path completo combinando path + entry_point
- Usar valores por defecto cuando campos no existen en JSON
- Agregar console.log para debug
- El campo user usa 'pablinux' por defecto (no está en JSON)
- Ahora los campos se llenan correctamente en lugar de mostrar solo placeholders
2026-01-21 18:00:56 -05:00
9e56490b05 fix: Agregar ruta /edit al router web (404 fix)
- Agregada ruta GET /edit al web_router
- Creado edit_handler() que sirve edit.html
- Resuelve error 404 al intentar acceder a /edit?app=NOMBRE
- Ahora el botón Editar del panel funciona correctamente
2026-01-21 17:57:34 -05:00
d2b8d0222c 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 
2026-01-21 17:40:37 -05:00
d8b3214ede fix: Mejorar robustez del endpoint de eliminación de apps
- unregister_app_handler ahora intenta eliminar de 3 fuentes:
  1. AppManager (memoria) - puede fallar si app no se registró bien
  2. JSON (soft delete) - marca como eliminada en monitored_apps.json
  3. systemd (físico) - elimina .service, stop, disable, daemon-reload
- Logs detallados en cada paso (INFO/WARNING)
- Ya no falla con 'Aplicación no encontrada' si solo falta en memoria
- Resuelve problema de GatewaySIGMA que no se podía eliminar
- Operación best-effort: intenta todo sin fallar si un paso falla
2026-01-21 17:12:10 -05:00
2f867cb7ed feat: Auto-carga de variables de entorno desde archivo .env
- Agregada dependencia dotenvy para parsear archivos .env
- Implementada función read_env_file() que lee y parsea archivos .env
- Soporta comentarios (#), líneas vacías, y valores con/sin comillas
- Auto-detección: si existe .env en WorkingDirectory, se carga automáticamente
- Merge inteligente: .env primero, luego variables manuales (sobrescriben)
- Las apps ahora pueden usar su .env sin tener que copiar 17+ variables manualmente
- Logs claros: informa cuántas variables se cargaron desde .env
- Beneficio: registrar apps es mucho más rápido y menos propenso a errores
2026-01-21 17:04:53 -05:00
6fa7b5c86c chore: Actualizar .gitignore para excluir logs y config
- Agregar logs/*.log para ignorar archivos de logs del sistema
- Agregar config/monitored_apps.json para evitar commits de configuración local
- Mantener solo archivos de código fuente en el repositorio
2026-01-20 06:53:43 -05:00
fb3db3c713 fix: Mejorar claridad del modal de eliminación
- Mostrar nombre exacto del servicio (siax-app-NOMBRE.service) en lugar de comodín
- Actualizar texto para aclarar que es soft delete
- Agregar icono de archivo para indicar que se mantiene en historial
- Agregar mensaje informativo: 'Podrás restaurarla desde el historial'
- Cambiar colores para diferenciar eliminación física vs lógica
2026-01-20 06:49:31 -05:00
7a66f25150 fix: Evitar desbordamiento horizontal en logs
- Agregado overflow-x-auto a contenedores de logs
- Agregado break-words y overflow-wrap-anywhere a líneas de logs
- Aplicado tanto en logs de aplicaciones como en errores del sistema
- Las líneas largas ahora se ajustan correctamente sin scroll horizontal
2026-01-20 06:43:46 -05:00
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
60f38be957 feat: Agregar modal de confirmación para eliminar apps en panel web
Frontend (index.html):
- Modal elegante con backdrop blur y animación
- Muestra nombre de la app a eliminar
- Lista detallada de lo que se eliminará:
  * Servicio systemd (siax-app-*.service)
  * Archivo .service en /etc/systemd/system/
  * Registro en monitored_apps.json
  * Historial de monitoreo

- Botón de eliminar solo visible si app está Stopped/Failed
- Diseño rojo con iconos Material Symbols
- Dos botones: Cancelar (gris) y Eliminar (rojo)
- Función openDeleteModal(appName)
- Función closeDeleteModal()
- Función confirmDelete() que llama a DELETE /api/apps/:name

Backend (ya existente, no modificado):
- DELETE /api/apps/:name elimina completamente:
  1. systemctl stop
  2. systemctl disable
  3. rm archivo .service
  4. systemctl daemon-reload
  5. Elimina de AppManager (memoria)
  6. Elimina de monitored_apps.json

Flujo de eliminación:
1. Usuario detiene app
2. Aparece botón 🗑️ Eliminar
3. Click → Modal de confirmación
4. Confirm → DELETE request
5. Backend elimina todo
6. Frontend recarga tabla
7. App desaparece completamente del sistema

Consecuencias de eliminación completa:
 No queda rastro en systemd
 No re-aparece en discovery
 No se envía a Cloud Central
 Limpieza total del sistema
2026-01-19 08:28:13 -05:00
6ab43980aa fix: Corregir carga de apps en sidebar de logs.html
Problema:
- Sidebar de logs.html no mostraba las apps
- Accedía a data.apps en lugar de data.data.apps
- La estructura de respuesta de /api/apps cambió a:
  { success: true, data: { apps: [...], total: N } }

Solución:
- Actualizar loadApps() en logs.html
- Acceder a result.data.apps correctamente
- Validar result.success antes de procesar

Ahora el sidebar muestra las apps correctamente como en index.html
2026-01-19 08:11:22 -05:00
16 changed files with 3707 additions and 40 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target /target
logs/*.log
config/monitored_apps.json

7
Cargo.lock generated
View File

@@ -281,6 +281,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -1332,6 +1338,7 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"dashmap", "dashmap",
"dotenvy",
"futures", "futures",
"regex", "regex",
"reqwest", "reqwest",

View File

@@ -17,6 +17,7 @@ tokio-stream = "0.1"
regex = "1.10" regex = "1.10"
thiserror = "1.0" thiserror = "1.0"
dashmap = "5.5" dashmap = "5.5"
dotenvy = "0.15"
[dev-dependencies] [dev-dependencies]
tempfile = "3.8" tempfile = "3.8"

View File

@@ -2,11 +2,27 @@
"apps": [ "apps": [
{ {
"name": "app_tareas", "name": "app_tareas",
"port": 3000 "service_name": "",
"path": "",
"port": 3000,
"entry_point": "",
"node_bin": "",
"mode": "production",
"service_file_path": "",
"deleted": true,
"deleted_at": "2026-01-21T18:01:42.273756980-05:00",
"deleted_reason": "Eliminada desde el panel de control"
}, },
{ {
"name": "fidelizacion", "name": "fidelizacion",
"port": 3001 "service_name": "",
"path": "",
"port": 3001,
"entry_point": "",
"node_bin": "",
"mode": "production",
"service_file_path": "",
"deleted": false
} }
] ]
} }

File diff suppressed because it is too large Load Diff

View File

@@ -60,20 +60,216 @@ 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,
user: config.user.clone(),
service_file_path,
registered_at: chrono::Local::now().to_rfc3339(),
deleted: false,
deleted_at: None,
deleted_reason: None,
environment: config.environment.clone(),
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( pub async fn unregister_app_handler(
State(state): State<Arc<ApiState>>, State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>, Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> { ) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
use crate::config::get_config_manager;
use crate::systemd::{SystemCtl, ServiceGenerator};
let logger = crate::logger::get_logger();
let service_name = format!("siax-app-{}.service", app_name);
logger.info("API", &format!("🗑️ Solicitud de eliminación para: {}", app_name));
// Intentar 1: Eliminar desde AppManager (si está en memoria)
let mut deleted_from_memory = false;
match state.app_manager.unregister_app(&app_name) { match state.app_manager.unregister_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse { Ok(_) => {
app_name: app_name.clone(), logger.info("API", &format!("✅ Eliminado desde AppManager: {}", app_name));
operation: "unregister".to_string(), deleted_from_memory = true;
success: true, }
message: "Aplicación eliminada exitosamente".to_string(), Err(e) => {
}))), logger.warning("API", &format!("App no encontrada en AppManager: {}", e), None);
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), }
} }
// Intentar 2: Soft delete en JSON (siempre intentar)
let config_manager = get_config_manager();
let delete_reason = Some("Eliminada desde el panel de control".to_string());
match config_manager.soft_delete_app(&app_name, delete_reason) {
Ok(_) => {
logger.info("API", &format!("✅ Soft delete en JSON: {}", app_name));
}
Err(e) => {
logger.warning("API", &format!("No se pudo hacer soft delete en JSON: {}", e), None);
}
}
// Intentar 3: Eliminar servicio systemd físicamente (siempre intentar)
let _ = SystemCtl::stop(&service_name);
logger.info("API", &format!("Deteniendo servicio: {}", service_name));
let _ = SystemCtl::disable(&service_name);
logger.info("API", &format!("Deshabilitando servicio: {}", service_name));
match ServiceGenerator::delete_service_file(&service_name) {
Ok(_) => {
logger.info("API", &format!("✅ Archivo .service eliminado: {}", service_name));
}
Err(e) => {
logger.warning("API", &format!("No se pudo eliminar .service: {}", e), None);
}
}
let _ = SystemCtl::daemon_reload();
logger.info("API", "🔄 daemon-reload ejecutado");
Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "unregister".to_string(),
success: true,
message: format!("Aplicación '{}' eliminada exitosamente (servicio systemd + soft delete en JSON)", app_name),
})))
} }
pub async fn start_app_handler( pub async fn start_app_handler(
@@ -124,6 +320,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( pub async fn get_app_status_handler(
State(_state): State<Arc<ApiState>>, State(_state): State<Arc<ApiState>>,
Path(app_name): Path<String>, Path(app_name): Path<String>,
@@ -358,3 +578,52 @@ pub async fn get_system_error_logs() -> Result<Json<serde_json::Value>, StatusCo
} }
} }
} }
/// Endpoint para obtener apps eliminadas (soft delete history)
pub async fn get_deleted_apps_handler() -> Result<Json<serde_json::Value>, StatusCode> {
use crate::config::get_config_manager;
let config_manager = get_config_manager();
let deleted_apps = config_manager.get_deleted_apps();
// Formatear respuesta con información de cada app eliminada
let apps_info: Vec<serde_json::Value> = deleted_apps.iter().map(|app| {
serde_json::json!({
"name": app.name,
"port": app.port,
"path": app.path,
"entry_point": app.entry_point,
"mode": app.mode,
"registered_at": app.registered_at,
"deleted_at": app.deleted_at,
"deleted_reason": app.deleted_reason,
})
}).collect();
Ok(Json(serde_json::json!({
"success": true,
"data": {
"apps": apps_info,
"total": apps_info.len()
}
})))
}
/// Endpoint para restaurar una app eliminada (soft delete)
pub async fn restore_app_handler(
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
use crate::config::get_config_manager;
let config_manager = get_config_manager();
match config_manager.restore_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "restore".to_string(),
success: true,
message: format!("Aplicación '{}' restaurada exitosamente. Nota: el servicio systemd debe ser recreado manualmente.", app_name),
}))),
Err(e) => Ok(Json(ApiResponse::error(e))),
}
}

View File

@@ -32,6 +32,10 @@ pub struct MonitoredApp {
#[serde(default = "default_mode")] #[serde(default = "default_mode")]
pub mode: String, pub mode: String,
/// Usuario del sistema que ejecuta la aplicación
#[serde(default = "default_user")]
pub user: String,
/// Ruta completa al archivo .service de systemd /// Ruta completa al archivo .service de systemd
#[serde(default)] #[serde(default)]
pub service_file_path: String, pub service_file_path: String,
@@ -40,6 +44,25 @@ pub struct MonitoredApp {
#[serde(default, skip_serializing_if = "String::is_empty", rename = "reg")] #[serde(default, skip_serializing_if = "String::is_empty", rename = "reg")]
pub registered_at: String, pub registered_at: String,
// --- SOFT DELETE FIELDS ---
/// Indica si la app fue eliminada (soft delete)
#[serde(default)]
pub deleted: bool,
/// Fecha de eliminación (ISO 8601)
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
/// Razón de eliminación (opcional)
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_reason: Option<String>,
// --- VARIABLES DE ENTORNO ADICIONALES ---
/// Variables de entorno ADICIONALES (las del .env se cargan con EnvironmentFile)
/// Solo almacenamos aquí las variables que el usuario agrega manualmente desde el panel
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub environment: std::collections::HashMap<String, String>,
// DEPRECATED: Mantener por compatibilidad con versiones antiguas // DEPRECATED: Mantener por compatibilidad con versiones antiguas
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub systemd_service: Option<String>, pub systemd_service: Option<String>,
@@ -51,6 +74,13 @@ fn default_mode() -> String {
"production".to_string() "production".to_string()
} }
fn default_user() -> String {
// Intentar obtener el usuario actual del sistema
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "root".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub apps: Vec<MonitoredApp>, pub apps: Vec<MonitoredApp>,
@@ -131,11 +161,30 @@ impl ConfigManager {
Ok(()) Ok(())
} }
/// Obtiene las apps activas (no eliminadas)
pub fn get_apps(&self) -> Vec<MonitoredApp> { pub fn get_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap();
config.apps.iter()
.filter(|app| !app.deleted)
.cloned()
.collect()
}
/// Obtiene TODAS las apps, incluyendo las eliminadas
pub fn get_all_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap(); let config = self.config.read().unwrap();
config.apps.clone() config.apps.clone()
} }
/// Obtiene solo las apps eliminadas
pub fn get_deleted_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap();
config.apps.iter()
.filter(|app| app.deleted)
.cloned()
.collect()
}
/// Agrega una app con información completa /// Agrega una app con información completa
pub fn add_app_full(&self, app: MonitoredApp) -> Result<(), String> { pub fn add_app_full(&self, app: MonitoredApp) -> Result<(), String> {
let mut config = self.config.write().unwrap(); let mut config = self.config.write().unwrap();
@@ -168,8 +217,13 @@ impl ConfigManager {
entry_point: String::new(), entry_point: String::new(),
node_bin: String::new(), node_bin: String::new(),
mode: "production".to_string(), mode: "production".to_string(),
user: default_user(),
service_file_path: String::new(), service_file_path: String::new(),
registered_at, registered_at,
deleted: false,
deleted_at: None,
deleted_reason: None,
environment: std::collections::HashMap::new(),
systemd_service: None, systemd_service: None,
created_at: None, created_at: None,
}; };
@@ -177,6 +231,55 @@ impl ConfigManager {
self.add_app_full(app) self.add_app_full(app)
} }
/// Realiza un soft delete: marca la app como eliminada pero mantiene el registro
pub fn soft_delete_app(&self, name: &str, reason: Option<String>) -> Result<(), String> {
let mut config = self.config.write().unwrap();
// Buscar la app
let app = config.apps.iter_mut().find(|a| a.name == name && !a.deleted);
match app {
Some(app) => {
// Marcar como eliminada
app.deleted = true;
app.deleted_at = Some(chrono::Local::now().to_rfc3339());
app.deleted_reason = reason;
// 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))
}
}
None => Err(format!("La app '{}' no se encontró o ya está eliminada", name))
}
}
/// Restaura una app previamente eliminada (soft delete)
pub fn restore_app(&self, name: &str) -> Result<(), String> {
let mut config = self.config.write().unwrap();
// Buscar la app eliminada
let app = config.apps.iter_mut().find(|a| a.name == name && a.deleted);
match app {
Some(app) => {
// Restaurar
app.deleted = false;
app.deleted_at = None;
app.deleted_reason = None;
// 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))
}
}
None => Err(format!("La app '{}' no se encontró en apps eliminadas", name))
}
}
/// HARD DELETE: Elimina permanentemente una app del JSON (usar con precaución)
pub fn remove_app(&self, name: &str) -> Result<(), String> { pub fn remove_app(&self, name: &str) -> Result<(), String> {
let mut config = self.config.write().unwrap(); let mut config = self.config.write().unwrap();

View File

@@ -255,8 +255,13 @@ pub fn sync_discovered_services(services: Vec<DiscoveredService>) {
entry_point: service.entry_point.unwrap_or_default(), entry_point: service.entry_point.unwrap_or_default(),
node_bin: service.node_bin.unwrap_or_default(), node_bin: service.node_bin.unwrap_or_default(),
mode: service.node_env, mode: service.node_env,
user: service.user.clone().unwrap_or_else(|| "root".to_string()),
service_file_path: service.service_file.clone(), service_file_path: service.service_file.clone(),
registered_at, registered_at,
deleted: false,
deleted_at: None,
deleted_reason: None,
environment: std::collections::HashMap::new(),
systemd_service: None, systemd_service: None,
created_at: None, created_at: None,
}; };

View File

@@ -54,6 +54,7 @@ pub fn create_web_router() -> Router {
.route("/scan", get(scan_processes_handler)) .route("/scan", get(scan_processes_handler))
.route("/select", get(select_processes_handler)) .route("/select", get(select_processes_handler))
.route("/register", get(register_handler)) .route("/register", get(register_handler))
.route("/edit", get(edit_handler))
.route("/add-process", post(add_process_handler)) .route("/add-process", post(add_process_handler))
.route("/logs", get(logs_handler)) .route("/logs", get(logs_handler))
.route("/clear-logs", post(clear_logs_handler)) .route("/clear-logs", post(clear_logs_handler))
@@ -122,6 +123,11 @@ async fn register_handler() -> Html<String> {
Html(template.to_string()) Html(template.to_string())
} }
async fn edit_handler() -> Html<String> {
let template = include_str!("../web/edit.html");
Html(template.to_string())
}
async fn api_docs_handler() -> Html<String> { async fn api_docs_handler() -> Html<String> {
let template = include_str!("../web/api-docs.html"); let template = include_str!("../web/api-docs.html");
Html(template.to_string()) Html(template.to_string())

View File

@@ -70,11 +70,13 @@ async fn main() {
.route("/api/monitored", get(api::get_monitored_apps_handler)) .route("/api/monitored", get(api::get_monitored_apps_handler))
.route("/api/logs/errors", get(api::get_system_error_logs)) .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", get(api::list_apps_handler).post(api::register_app_handler))
.route("/api/apps/:name", delete(api::unregister_app_handler)) .route("/api/apps/deleted", get(api::get_deleted_apps_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/status", get(api::get_app_status_handler))
.route("/api/apps/:name/start", post(api::start_app_handler)) .route("/api/apps/:name/start", post(api::start_app_handler))
.route("/api/apps/:name/stop", post(api::stop_app_handler)) .route("/api/apps/:name/stop", post(api::stop_app_handler))
.route("/api/apps/:name/restart", post(api::restart_app_handler)) .route("/api/apps/:name/restart", post(api::restart_app_handler))
.route("/api/apps/:name/restore", post(api::restore_app_handler))
.route("/api/scan", get(api::scan_processes_handler)) .route("/api/scan", get(api::scan_processes_handler))
.with_state(api_state); .with_state(api_state);

View File

@@ -86,8 +86,13 @@ impl AppManager {
entry_point, entry_point,
node_bin, node_bin,
mode, mode,
user: config.user.clone(),
service_file_path, service_file_path,
registered_at, registered_at,
deleted: false,
deleted_at: None,
deleted_reason: None,
environment: config.environment.clone(),
systemd_service: None, systemd_service: None,
created_at: None, created_at: None,
}; };
@@ -104,7 +109,7 @@ impl AppManager {
pub fn unregister_app(&self, app_name: &str) -> Result<()> { pub fn unregister_app(&self, app_name: &str) -> Result<()> {
let logger = get_logger(); let logger = get_logger();
logger.info("AppManager", &format!("Desregistrando aplicación: {}", app_name)); logger.info("AppManager", &format!("Desregistrando aplicación (soft delete): {}", app_name));
// Obtener configuración // Obtener configuración
let config = self.apps.get(app_name) let config = self.apps.get(app_name)
@@ -119,7 +124,7 @@ impl AppManager {
// Deshabilitar el servicio // Deshabilitar el servicio
let _ = SystemCtl::disable(&service_name); let _ = SystemCtl::disable(&service_name);
// Eliminar archivo de servicio // Eliminar archivo de servicio (físicamente)
ServiceGenerator::delete_service_file(&service_name)?; ServiceGenerator::delete_service_file(&service_name)?;
// Recargar daemon // Recargar daemon
@@ -128,13 +133,14 @@ impl AppManager {
// Eliminar de memoria // Eliminar de memoria
self.apps.remove(app_name); self.apps.remove(app_name);
// Eliminar de monitored_apps.json // SOFT DELETE en monitored_apps.json (mantener historial)
let config_manager = get_config_manager(); let config_manager = get_config_manager();
if let Err(e) = config_manager.remove_app(app_name) { let delete_reason = Some("Eliminada desde el panel de control".to_string());
logger.warning("AppManager", "No se pudo eliminar de monitored_apps.json", Some(&e)); if let Err(e) = config_manager.soft_delete_app(app_name, delete_reason) {
logger.warning("AppManager", "No se pudo hacer soft delete en monitored_apps.json", Some(&e));
} }
logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente", app_name)); logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente (soft delete)", app_name));
Ok(()) Ok(())
} }

View File

@@ -3,6 +3,7 @@ use crate::models::ServiceConfig;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
use std::collections::HashMap;
use crate::logger::get_logger; use crate::logger::get_logger;
pub struct ServiceGenerator; pub struct ServiceGenerator;
@@ -74,7 +75,7 @@ impl ServiceGenerator {
format!("{} {}", executable, config.script_path) format!("{} {}", executable, config.script_path)
}; };
// Generar variables de entorno del usuario // Generar líneas de variables de entorno ADICIONALES (solo las del config, no del .env)
let mut env_lines: Vec<String> = config.environment let mut env_lines: Vec<String> = config.environment
.iter() .iter()
.map(|(key, value)| format!("Environment=\"{}={}\"", key, value)) .map(|(key, value)| format!("Environment=\"{}={}\"", key, value))
@@ -99,11 +100,17 @@ impl ServiceGenerator {
} }
} }
let env_vars = env_lines.join("\n");
// Agregar SyslogIdentifier para logs más claros // Agregar SyslogIdentifier para logs más claros
let syslog_id = format!("SyslogIdentifier=siax-app-{}", config.app_name); let syslog_id = format!("SyslogIdentifier=siax-app-{}", config.app_name);
// Verificar si existe .env en el proyecto
let env_file_path = Path::new(&config.working_directory).join(".env");
let has_env_file = env_file_path.exists();
if has_env_file {
logger.info("ServiceGenerator", &format!("📄 .env encontrado, usando EnvironmentFile: {}", env_file_path.display()));
}
// Construir el servicio con orden lógico // Construir el servicio con orden lógico
let mut service = format!( let mut service = format!(
r#"[Unit] r#"[Unit]
@@ -120,9 +127,26 @@ WorkingDirectory={}
config.working_directory config.working_directory
); );
// Agregar variables de entorno (PATH primero, luego las demás) // Agregar PATH si usa NVM (debe ir primero)
if !env_vars.is_empty() { // Extraer PATH de env_lines si está en la primera posición
service.push_str(&env_vars); let mut path_line: Option<String> = None;
if !env_lines.is_empty() && env_lines[0].starts_with("Environment=PATH=") {
path_line = Some(env_lines.remove(0));
}
if let Some(path) = path_line {
service.push_str(&path);
service.push('\n');
}
// ✅ AGREGAR EnvironmentFile si existe .env en el proyecto
if has_env_file {
service.push_str(&format!("EnvironmentFile={}\n", env_file_path.display()));
}
// Agregar variables de entorno ADICIONALES (las del formulario/JSON)
if !env_lines.is_empty() {
service.push_str(&env_lines.join("\n"));
service.push('\n'); service.push('\n');
} }
@@ -295,4 +319,55 @@ WantedBy=multi-user.target
Err(_) => false, Err(_) => false,
} }
} }
/// Lee el archivo .env del directorio de trabajo y retorna las variables
pub fn read_env_file(working_directory: &str) -> HashMap<String, String> {
let logger = get_logger();
let env_path = Path::new(working_directory).join(".env");
let mut env_vars = HashMap::new();
if !env_path.exists() {
logger.info("ServiceGenerator", &format!("No se encontró archivo .env en: {}", env_path.display()));
return env_vars;
}
logger.info("ServiceGenerator", &format!("Leyendo archivo .env desde: {}", env_path.display()));
match fs::read_to_string(&env_path) {
Ok(content) => {
for line in content.lines() {
let line = line.trim();
// Ignorar líneas vacías y comentarios
if line.is_empty() || line.starts_with('#') {
continue;
}
// Parsear línea KEY=VALUE
if let Some(pos) = line.find('=') {
let key = line[..pos].trim().to_string();
let mut value = line[pos + 1..].trim().to_string();
// Remover comillas simples o dobles
if (value.starts_with('\'') && value.ends_with('\'')) ||
(value.starts_with('"') && value.ends_with('"')) {
value = value[1..value.len()-1].to_string();
}
if !key.is_empty() {
env_vars.insert(key, value);
}
}
}
logger.info("ServiceGenerator", &format!("✅ Cargadas {} variables desde .env", env_vars.len()));
}
Err(e) => {
logger.warning("ServiceGenerator", &format!("Error leyendo .env: {}", e), None);
}
}
env_vars
}
} }

View File

@@ -497,24 +497,92 @@ sudo journalctl -u siax_monitor -f
4. Verificar logs en pestaña "Logs de App" 4. Verificar logs en pestaña "Logs de App"
5. Verificar errores del sistema en pestaña "Errores del Sistema" 5. Verificar errores del sistema en pestaña "Errores del Sistema"
===============================================================================
🔮 FASE 5 - MEJORAS FUTURAS Y TAREAS PENDIENTES
===============================================================================
**Fase 5.1: Script de Inicialización de .env** 🔄 PENDIENTE
[ ] Crear script/comando para sincronizar .env desde servidor central
[ ] Implementar endpoint en API Central para servir .env de producción
[ ] Script de deploy que descargue .env automáticamente:
- Opción 1: GET https://api-central.com/env/{app_name}
- Opción 2: SCP desde servidor de secrets
- Opción 3: Integración con Vault/Secrets Manager
[ ] Validación de variables requeridas antes de iniciar servicio
[ ] Logging de variables faltantes (sin exponer valores sensibles)
[ ] Documentación de variables requeridas por app
**Motivación:**
- Actualmente .env está en .gitignore (correcto para seguridad)
- Al deployar, el .env NO se copia al servidor
- Las apps fallan con "DB param: undefined"
- Proceso manual de copiar .env es propenso a errores
- Necesario automatizar la distribución segura de secrets
**Implementación Sugerida:**
```bash
# Script: sync_env.sh
#!/bin/bash
APP_NAME=$1
API_CENTRAL="https://api-central.telcotronics.com"
# Descargar .env desde servidor central
curl -H "Authorization: Bearer $SECRET_TOKEN" \
"$API_CENTRAL/secrets/$APP_NAME/.env" \
-o /home/user_apps/apps/$APP_NAME/.env
# Verificar descarga
if [ -f "/home/user_apps/apps/$APP_NAME/.env" ]; then
echo "✅ .env descargado correctamente"
# Re-registrar app para cargar variables
curl -X PUT http://localhost:8080/api/apps/$APP_NAME \
-H "Content-Type: application/json" \
-d @/tmp/app_config.json
else
echo "❌ Error descargando .env"
exit 1
fi
```
**Fase 5.2: Template de .env** 🔄 PENDIENTE
[ ] Crear .env.example en cada proyecto
[ ] Documentar variables requeridas vs opcionales
[ ] Script de validación: check_env.sh
[ ] Generar .env desde template interactivo
**Fase 5.3: Gestión Centralizada de Secrets** 🔄 PENDIENTE
[ ] Integración con HashiCorp Vault
[ ] Soporte para AWS Secrets Manager
[ ] Rotación automática de passwords
[ ] Auditoría de acceso a secrets
=============================================================================== ===============================================================================
📊 MÉTRICAS DEL PROYECTO 📊 MÉTRICAS DEL PROYECTO
=============================================================================== ===============================================================================
**Líneas de código:** ~3,500 **Líneas de código:** ~4,200
**Archivos Rust:** 15 **Archivos Rust:** 15
**Archivos HTML:** 8 **Archivos HTML:** 9 (agregado edit.html)
**Endpoints API:** 12 **Endpoints API:** 15 (GET/PUT/DELETE /apps/:name, GET /apps/deleted, POST /apps/:name/restore)
**Commits totales:** 15+ **Commits totales:** 25+
**Tiempo desarrollo:** ~3 días **Tiempo desarrollo:** ~4 días
**Bugs críticos resueltos:** 8 **Bugs críticos resueltos:** 12
**Fase actual:** 4.8 (Completada) **Fase actual:** 4.8 (Completada) + Mejoras (Soft Delete, CRUD Update, Auto .env)
**Nuevas Features:**
✅ Soft Delete con historial
✅ Función EDITAR apps (CRUD completo)
✅ Auto-carga de variables desde .env
✅ Campo 'user' en configuración
✅ Eliminación robusta (3 fuentes)
✅ UI mejorada (overflow logs, modal claro)
=============================================================================== ===============================================================================
🎉 FIN DEL DOCUMENTO 🎉 FIN DEL DOCUMENTO
=============================================================================== ===============================================================================
Última actualización: 2026-01-18 23:45:00 Última actualización: 2026-01-21 22:30:00
Actualizado por: Claude AI Assistant Actualizado por: Claude AI Assistant
Proyecto: SIAX Monitor v0.1.0 Proyecto: SIAX Monitor v0.1.0
Estado: PRODUCTION-READY ✅ Estado: PRODUCTION-READY ✅
Próxima fase: 5.1 (Script inicialización .env)

615
web/edit.html Normal file
View File

@@ -0,0 +1,615 @@
<!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() {
addEnvironmentVariable("", "");
}
function addEnvironmentVariable(key, value) {
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"
value="${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"
value="${value}"
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;
console.log("App data:", app); // Debug
// Llenar formulario con datos actuales
document.getElementById("app_name").value = app.name || "";
document.getElementById("app_name").readOnly = true; // No cambiar nombre
// Construir script_path completo
const scriptPath =
app.path && app.entry_point
? `${app.path}/${app.entry_point}`.replace(
"//",
"/",
)
: app.entry_point || "";
document.getElementById("script_path").value = scriptPath;
document.getElementById("working_directory").value =
app.path || "";
// Cargar usuario desde JSON (sin fallback)
document.getElementById("user").value = app.user;
document.getElementById("restart_policy").value = "always";
document.getElementById("app_type").value = "nodejs";
document.getElementById("description").value = "";
// ✅ Cargar variables de entorno ADICIONALES desde JSON
// (Las del .env se cargan automáticamente con EnvironmentFile)
if (
app.environment &&
Object.keys(app.environment).length > 0
) {
// Limpiar el campo vacío por defecto
document.getElementById("env-container").innerHTML = "";
// Agregar cada variable del JSON
Object.entries(app.environment).forEach(
([key, value]) => {
addEnvironmentVariable(key, value);
},
);
console.log(
`✅ Cargadas ${Object.keys(app.environment).length} variables adicionales desde JSON`,
);
}
} 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

@@ -397,6 +397,100 @@
> >
</div> </div>
</div> </div>
<!-- Historial de Apps Eliminadas Section -->
<div
id="deleted-apps-section"
class="mt-10 bg-white dark:bg-slate-800/30 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden hidden"
>
<div
class="p-6 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<div
class="size-10 rounded-full bg-red-500/10 flex items-center justify-center"
>
<span
class="material-symbols-outlined text-red-500"
>history</span
>
</div>
<div>
<h2
class="text-slate-900 dark:text-white text-xl font-bold"
>
Historial de Apps Eliminadas
</h2>
<p class="text-slate-500 text-sm">
Aplicaciones eliminadas que pueden ser
restauradas
</p>
</div>
</div>
<button
onclick="toggleDeletedApps()"
class="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<span class="material-symbols-outlined"
>expand_more</span
>
</button>
</div>
<div id="deleted-apps-content" class="hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 dark:bg-slate-800/50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Aplicación
</th>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Puerto
</th>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Eliminada
</th>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Razón
</th>
<th
class="px-6 py-3 text-right text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Acciones
</th>
</tr>
</thead>
<tbody
id="deleted-apps-list"
class="divide-y divide-slate-200 dark:divide-slate-800"
>
<!-- Deleted apps will be loaded here -->
</tbody>
</table>
</div>
<div
id="deleted-apps-empty"
class="hidden p-8 text-center"
>
<span
class="material-symbols-outlined text-6xl text-slate-300 dark:text-slate-700 mb-3"
>check_circle</span
>
<p class="text-slate-500 dark:text-slate-400">
No hay apps eliminadas en el historial
</p>
</div>
</div>
</div>
<!-- Quick Action Links --> <!-- Quick Action Links -->
<div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-6">
<div <div
@@ -471,6 +565,124 @@
</div> </div>
</footer> </footer>
</div> </div>
<!-- Modal de Confirmación para Eliminar -->
<div
id="delete-modal"
class="hidden fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<div
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full border border-slate-200 dark:border-slate-700 animate-in fade-in zoom-in duration-200"
>
<!-- Header -->
<div
class="flex items-center gap-3 p-6 border-b border-slate-200 dark:border-slate-700"
>
<div
class="size-12 rounded-full bg-red-500/10 flex items-center justify-center"
>
<span
class="material-symbols-outlined text-red-500 text-2xl"
>delete_forever</span
>
</div>
<div>
<h3
class="text-lg font-bold text-slate-900 dark:text-white"
>
Eliminar Aplicación
</h3>
<p class="text-sm text-slate-500">
Esta acción no se puede deshacer
</p>
</div>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<p
class="text-sm text-slate-700 dark:text-slate-300 mb-3"
>
¿Estás seguro de eliminar
<strong
id="delete-app-name"
class="text-red-600 dark:text-red-400"
></strong
>?
</p>
<p
class="text-xs text-slate-600 dark:text-slate-400 font-medium mb-2"
>
Esta acción eliminará:
</p>
<ul
class="text-xs text-slate-600 dark:text-slate-400 space-y-1.5"
>
<li class="flex items-center gap-2">
<span
class="material-symbols-outlined text-[14px] text-red-500"
>close</span
>
Servicio systemd:
<span
id="delete-service-name"
class="font-mono"
></span>
</li>
<li class="flex items-center gap-2">
<span
class="material-symbols-outlined text-[14px] text-red-500"
>close</span
>
Archivo .service en /etc/systemd/system/
</li>
<li class="flex items-center gap-2">
<span
class="material-symbols-outlined text-[14px] text-amber-500"
>archive</span
>
Se marcará como eliminada en monitored_apps.json
(soft delete)
</li>
<li
class="flex items-center gap-2 text-green-600 dark:text-green-400"
>
<span
class="material-symbols-outlined text-[14px]"
>info</span
>
Podrás restaurarla desde el historial
</li>
</ul>
</div>
</div>
<!-- Actions -->
<div
class="flex gap-3 p-6 border-t border-slate-200 dark:border-slate-700"
>
<button
onclick="closeDeleteModal()"
class="flex-1 px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
Cancelar
</button>
<button
onclick="confirmDelete()"
class="flex-1 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
<span class="material-symbols-outlined text-[18px]"
>delete</span
>
Eliminar
</button>
</div>
</div>
</div>
<script> <script>
async function loadApps() { async function loadApps() {
try { try {
@@ -580,11 +792,27 @@
</button> </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" <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'" onclick="window.location.href='/logs'"
title="Ver logs"> title="Ver logs">
<span class="material-symbols-outlined text-[20px]">visibility</span> <span class="material-symbols-outlined text-[20px]">visibility</span>
</button> </button>
${
app.status !== "Running"
? `
<button class="text-red-500 hover:text-red-400 transition-colors p-1.5 rounded hover:bg-red-900/20"
onclick="openDeleteModal('${app.name}')"
title="Eliminar">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
`
: ""
}
</div> </div>
</td> </td>
</tr> </tr>
@@ -643,12 +871,180 @@
} }
} }
// Modal de confirmación para eliminar
let appToDelete = null;
function openDeleteModal(appName) {
appToDelete = appName;
document.getElementById("delete-app-name").textContent =
appName;
document.getElementById("delete-service-name").textContent =
`siax-app-${appName}.service`;
document
.getElementById("delete-modal")
.classList.remove("hidden");
document.body.style.overflow = "hidden";
}
function closeDeleteModal() {
appToDelete = null;
document.getElementById("delete-modal").classList.add("hidden");
document.body.style.overflow = "auto";
}
async function confirmDelete() {
if (!appToDelete) return;
const appName = appToDelete;
closeDeleteModal();
try {
const response = await fetch(`/api/apps/${appName}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
alert(`${result.data.message}`);
loadApps();
loadDeletedApps(); // Recargar historial de eliminadas
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (error) {
console.error("Error:", error);
alert("❌ Error al eliminar la aplicación");
}
}
function toggleMenu() { function toggleMenu() {
const menu = document.getElementById("mobile-menu"); const menu = document.getElementById("mobile-menu");
menu.classList.toggle("hidden"); menu.classList.toggle("hidden");
} }
window.addEventListener("DOMContentLoaded", loadApps); // Funciones para apps eliminadas (soft delete)
async function loadDeletedApps() {
try {
const response = await fetch("/api/apps/deleted");
const result = await response.json();
if (
!result.success ||
!result.data ||
!result.data.apps ||
result.data.apps.length === 0
) {
// No hay apps eliminadas, ocultar sección
document
.getElementById("deleted-apps-section")
.classList.add("hidden");
return;
}
// Mostrar sección si hay apps eliminadas
document
.getElementById("deleted-apps-section")
.classList.remove("hidden");
const deletedAppsList =
document.getElementById("deleted-apps-list");
const emptyMessage =
document.getElementById("deleted-apps-empty");
deletedAppsList.innerHTML = result.data.apps
.map((app) => {
const deletedDate = app.deleted_at
? new Date(app.deleted_at).toLocaleString(
"es-ES",
)
: "Desconocida";
const reason =
app.deleted_reason || "Sin razón especificada";
return `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
<span class="material-symbols-outlined text-slate-500 text-xl">deployed_code_history</span>
</div>
<div>
<p class="text-slate-900 dark:text-white font-semibold text-sm">${app.name}</p>
<p class="text-slate-500 text-xs">${app.path || "Sin ruta"}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<span class="text-slate-600 dark:text-slate-400 text-sm">${app.port}</span>
</td>
<td class="px-6 py-4">
<span class="text-slate-600 dark:text-slate-400 text-sm">${deletedDate}</span>
</td>
<td class="px-6 py-4">
<span class="text-slate-600 dark:text-slate-400 text-sm">${reason}</span>
</td>
<td class="px-6 py-4 text-right">
<button
onclick="restoreApp('${app.name}')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded-lg transition-colors"
>
<span class="material-symbols-outlined text-sm">restore</span>
Restaurar
</button>
</td>
</tr>
`;
})
.join("");
emptyMessage.classList.add("hidden");
} catch (error) {
console.error("Error loading deleted apps:", error);
}
}
function toggleDeletedApps() {
const content = document.getElementById("deleted-apps-content");
content.classList.toggle("hidden");
}
async function restoreApp(appName) {
const confirmed = confirm(
`¿Estás seguro de restaurar la aplicación "${appName}"?\n\nNota: Solo se restaurará el registro en el JSON. El servicio systemd debe ser recreado manualmente.`,
);
if (!confirmed) return;
try {
const response = await fetch(
`/api/apps/${appName}/restore`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
const result = await response.json();
if (result.success) {
alert(`${result.data.message}`);
loadApps();
loadDeletedApps();
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (error) {
console.error("Error:", error);
alert("❌ Error al restaurar la aplicación");
}
}
window.addEventListener("DOMContentLoaded", () => {
loadApps();
loadDeletedApps();
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -238,9 +238,12 @@
<!-- Tab Content: App Logs --> <!-- Tab Content: App Logs -->
<div <div
id="content-app-logs" id="content-app-logs"
class="flex-1 bg-[#0a0f16] overflow-y-auto p-4 font-mono text-sm tab-content" class="flex-1 bg-[#0a0f16] overflow-y-auto overflow-x-auto p-4 font-mono text-sm tab-content"
> >
<div id="log-container" class="space-y-1"> <div
id="log-container"
class="space-y-1 break-words overflow-wrap-anywhere"
>
<!-- Welcome Message --> <!-- Welcome Message -->
<div class="text-[#9dabb9] opacity-50"> <div class="text-[#9dabb9] opacity-50">
<span class="text-green-400"></span> SIAX Monitor <span class="text-green-400"></span> SIAX Monitor
@@ -256,9 +259,12 @@
<!-- Tab Content: System Errors --> <!-- Tab Content: System Errors -->
<div <div
id="content-system-errors" id="content-system-errors"
class="hidden flex-1 bg-[#0a0f16] overflow-y-auto p-4 font-mono text-sm tab-content" class="hidden flex-1 bg-[#0a0f16] overflow-y-auto overflow-x-auto p-4 font-mono text-sm tab-content"
> >
<div id="system-errors-container" class="space-y-1"> <div
id="system-errors-container"
class="space-y-1 break-words overflow-wrap-anywhere"
>
<div class="text-[#9dabb9] opacity-50"> <div class="text-[#9dabb9] opacity-50">
<span class="text-yellow-400"></span> Cargando logs <span class="text-yellow-400"></span> Cargando logs
de errores del sistema... de errores del sistema...
@@ -284,17 +290,22 @@
try { try {
const response = await fetch("/api/apps"); const response = await fetch("/api/apps");
const data = await response.json(); const result = await response.json();
loading.classList.add("hidden"); loading.classList.add("hidden");
if (!data.apps || data.apps.length === 0) { if (
!result.success ||
!result.data ||
!result.data.apps ||
result.data.apps.length === 0
) {
empty.classList.remove("hidden"); empty.classList.remove("hidden");
return; return;
} }
appList.classList.remove("hidden"); appList.classList.remove("hidden");
appList.innerHTML = data.apps appList.innerHTML = result.data.apps
.map((app) => { .map((app) => {
const statusColor = const statusColor =
app.status === "Running" app.status === "Running"
@@ -400,7 +411,8 @@
function appendLog(type, message, logData = null) { function appendLog(type, message, logData = null) {
const logContainer = document.getElementById("log-container"); const logContainer = document.getElementById("log-container");
const logEntry = document.createElement("div"); const logEntry = document.createElement("div");
logEntry.className = "log-line"; logEntry.className =
"log-line break-words overflow-wrap-anywhere";
const timestamp = new Date() const timestamp = new Date()
.toISOString() .toISOString()
@@ -567,7 +579,7 @@
color = "text-blue-400"; color = "text-blue-400";
} }
return `<div class="log-line ${color}">${icon} ${escapeHtml(line)}</div>`; return `<div class="log-line break-words overflow-wrap-anywhere ${color}">${icon} ${escapeHtml(line)}</div>`;
}) })
.join(""); .join("");