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:
2026-01-13 08:24:13 -05:00
parent 3595e55a1e
commit b0489739cf
33 changed files with 6893 additions and 1261 deletions

View File

@@ -1,246 +1,471 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Logs del Sistema - SIAX</title>
<style>
body {
background: #0f172a;
color: white;
font-family: 'Courier New', monospace;
padding: 40px;
margin: 0;
}
h1 {
color: #3b82f6;
font-family: sans-serif;
}
.controls {
margin: 20px 0;
display: flex;
gap: 10px;
align-items: center;
}
.btn {
padding: 10px 20px;
border: none;
color: white;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #3b82f6;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-danger {
background: #ef4444;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: #64748b;
}
.btn-secondary:hover {
background: #475569;
}
.stats {
background: #1e293b;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 20px;
}
.stat-item {
flex: 1;
text-align: center;
padding: 15px;
border-radius: 6px;
}
.stat-info {
background: rgba(59, 130, 246, 0.1);
border: 1px solid #3b82f6;
}
.stat-warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid #f59e0b;
}
.stat-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
}
.stat-critical {
background: rgba(220, 38, 38, 0.1);
border: 1px solid #dc2626;
}
.stat-number {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 12px;
text-transform: uppercase;
opacity: 0.7;
}
.log-entry {
background: #1e293b;
padding: 15px;
margin: 10px 0;
border-radius: 8px;
border-left: 4px solid;
font-size: 13px;
line-height: 1.6;
}
.log-info {
border-left-color: #3b82f6;
}
.log-warning {
border-left-color: #f59e0b;
}
.log-error {
border-left-color: #ef4444;
}
.log-critical {
border-left-color: #dc2626;
background: rgba(220, 38, 38, 0.1);
}
.log-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-weight: bold;
}
.log-timestamp {
color: #94a3b8;
font-size: 11px;
}
.log-level {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
}
.log-module {
color: #60a5fa;
}
.log-message {
margin: 8px 0;
}
.log-details {
background: #0f172a;
padding: 10px;
border-radius: 4px;
margin-top: 8px;
color: #94a3b8;
font-size: 12px;
}
.no-logs {
background: #1e293b;
padding: 40px;
text-align: center;
border-radius: 8px;
color: #94a3b8;
}
.filter-bar {
background: #1e293b;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 5px;
}
.filter-checkbox input {
width: 18px;
height: 18px;
cursor: pointer;
}
.filter-checkbox label {
cursor: pointer;
user-select: none;
}
</style>
</head>
<body>
<h1>📋 Logs del Sistema SIAX</h1>
<!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>Visor de Registros - SIAX Monitor</title>
<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=JetBrains+Mono:wght@400;500;600&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"],
mono: ["JetBrains Mono", "monospace"],
},
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-[1400px] 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"
>
<span class="material-symbols-outlined text-white"
>monitoring</span
>
</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"
>Agregar Detectada</a
>
<a
class="text-[#9dabb9] text-sm font-medium hover:text-white transition-colors"
href="/register"
>Nueva App</a
>
<a
class="text-primary text-sm font-medium border-b-2 border-primary pb-1"
href="/logs"
>Registros</a
>
</nav>
</div>
</div>
</header>
<div class="controls">
<button onclick="location.reload()" class="btn btn-primary">🔄 Refrescar</button>
<button onclick="clearLogs()" class="btn btn-danger">🗑️ Limpiar Logs</button>
<a href="/" class="btn btn-secondary">← Volver al Panel</a>
</div>
<div class="flex-1 flex max-w-[1400px] mx-auto w-full">
<!-- Sidebar - App List -->
<aside
class="w-64 border-r border-[#283039] bg-[#161f2a] p-4 space-y-4 overflow-y-auto"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-white font-bold text-sm">Aplicaciones</h3>
<button
onclick="loadApps()"
class="text-[#9dabb9] hover:text-white transition-colors"
>
<span class="material-symbols-outlined text-[18px]"
>refresh</span
>
</button>
</div>
<div class="stats">
{{STATS}}
</div>
<div id="app-list" class="space-y-2">
<!-- Apps will be loaded here -->
</div>
<div class="filter-bar">
<span style="color: #60a5fa; font-weight: bold;">Filtrar por nivel:</span>
<div class="filter-checkbox">
<input type="checkbox" id="filter-info" checked onchange="filterLogs()">
<label for="filter-info"> Info</label>
</div>
<div class="filter-checkbox">
<input type="checkbox" id="filter-warning" checked onchange="filterLogs()">
<label for="filter-warning">⚠️ Warning</label>
</div>
<div class="filter-checkbox">
<input type="checkbox" id="filter-error" checked onchange="filterLogs()">
<label for="filter-error">❌ Error</label>
</div>
<div class="filter-checkbox">
<input type="checkbox" id="filter-critical" checked onchange="filterLogs()">
<label for="filter-critical">🔥 Critical</label>
</div>
</div>
<!-- Loading State -->
<div id="sidebar-loading" class="hidden text-center py-8">
<span
class="material-symbols-outlined text-primary text-3xl animate-spin"
>progress_activity</span
>
</div>
<div id="logs-container">
{{LOGS}}
</div>
<!-- Empty State -->
<div id="sidebar-empty" class="hidden text-center py-8">
<span
class="material-symbols-outlined text-[#9dabb9] text-3xl"
>inbox</span
>
<p class="text-[#9dabb9] text-xs mt-2">
No hay apps registradas
</p>
</div>
</aside>
<script>
function clearLogs() {
if (confirm('¿Estás seguro de que quieres eliminar todos los logs?')) {
fetch('/clear-logs', { method: 'POST' })
.then(() => location.reload())
.catch(err => alert('Error al limpiar logs: ' + err));
}
}
<!-- Main Content - Log Viewer -->
<main class="flex-1 flex flex-col">
<!-- Log Header -->
<div class="border-b border-[#283039] bg-[#161f2a] p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-primary"
>terminal</span
>
<div>
<h1
class="text-white text-lg font-bold"
id="current-app-name"
>
Selecciona una aplicación
</h1>
<p
class="text-[#9dabb9] text-xs"
id="connection-status"
>
Not connected
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
id="auto-scroll-btn"
onclick="toggleAutoScroll()"
class="flex items-center gap-2 px-3 py-2 bg-primary/20 border border-primary/30 rounded-lg text-primary text-sm font-medium hover:bg-primary/30 transition-colors"
>
<span
class="material-symbols-outlined text-[18px]"
>swap_vert</span
>
Auto-scroll: ON
</button>
<button
onclick="clearLogViewer()"
class="flex items-center gap-2 px-3 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm font-medium hover:bg-red-500/30 transition-colors"
>
<span
class="material-symbols-outlined text-[18px]"
>delete</span
>
Clear
</button>
</div>
</div>
</div>
function filterLogs() {
const showInfo = document.getElementById('filter-info').checked;
const showWarning = document.getElementById('filter-warning').checked;
const showError = document.getElementById('filter-error').checked;
const showCritical = document.getElementById('filter-critical').checked;
<!-- Terminal Log Output -->
<div
class="flex-1 bg-[#0a0f16] overflow-y-auto p-4 font-mono text-sm"
id="log-terminal"
>
<div id="log-container" class="space-y-1">
<!-- Welcome Message -->
<div class="text-[#9dabb9] opacity-50">
<span class="text-green-400"></span> SIAX Monitor
Log Viewer
</div>
<div class="text-[#9dabb9] opacity-50">
<span class="text-blue-400"></span> Select an
application from the sidebar to view logs
</div>
</div>
</div>
</main>
</div>
const logs = document.querySelectorAll('.log-entry');
logs.forEach(log => {
const level = log.dataset.level;
let show = false;
<script>
let ws = null;
let autoScroll = true;
let currentApp = null;
if (level === 'info' && showInfo) show = true;
if (level === 'warning' && showWarning) show = true;
if (level === 'error' && showError) show = true;
if (level === 'critical' && showCritical) show = true;
async function loadApps() {
const appList = document.getElementById("app-list");
const loading = document.getElementById("sidebar-loading");
const empty = document.getElementById("sidebar-empty");
log.style.display = show ? 'block' : 'none';
});
}
appList.classList.add("hidden");
loading.classList.remove("hidden");
empty.classList.add("hidden");
// Auto-refresh cada 30 segundos
setTimeout(() => location.reload(), 30000);
</script>
</body>
</html>
try {
const response = await fetch(
"http://localhost:8080/api/apps",
);
const data = await response.json();
loading.classList.add("hidden");
if (!data.apps || data.apps.length === 0) {
empty.classList.remove("hidden");
return;
}
appList.classList.remove("hidden");
appList.innerHTML = data.apps
.map((app) => {
const statusColor =
app.status === "Running"
? "text-green-400"
: app.status === "Stopped"
? "text-gray-400"
: "text-red-400";
const statusIcon =
app.status === "Running"
? "play_circle"
: app.status === "Stopped"
? "stop_circle"
: "error";
return `
<button
onclick="connectToApp('${app.name}')"
class="app-item w-full text-left p-3 rounded-lg border border-[#283039] hover:border-primary hover:bg-[#1c2730] transition-all ${currentApp === app.name ? "bg-primary/20 border-primary" : ""}"
data-app="${app.name}"
>
<div class="flex items-center justify-between mb-1">
<span class="text-white text-sm font-medium truncate">${app.name}</span>
<span class="material-symbols-outlined ${statusColor} text-[16px]">${statusIcon}</span>
</div>
<div class="text-[#9dabb9] text-xs">${app.status}</div>
</button>
`;
})
.join("");
} catch (error) {
console.error("Error loading apps:", error);
loading.classList.add("hidden");
empty.classList.remove("hidden");
}
}
function connectToApp(appName) {
// Close existing connection
if (ws) {
ws.close();
}
currentApp = appName;
document.getElementById("current-app-name").textContent =
appName;
document.getElementById("connection-status").textContent =
"Connecting...";
// Clear log container
const logContainer = document.getElementById("log-container");
logContainer.innerHTML = `
<div class="text-[#9dabb9]">
<span class="text-blue-400"></span> Connecting to ${appName} logs...
</div>
`;
// Update active state
document.querySelectorAll(".app-item").forEach((item) => {
if (item.dataset.app === appName) {
item.classList.add("bg-primary/20", "border-primary");
} else {
item.classList.remove(
"bg-primary/20",
"border-primary",
);
}
});
// Connect WebSocket
ws = new WebSocket(
`ws://localhost:8080/api/apps/${appName}/logs`,
);
ws.onopen = () => {
document.getElementById("connection-status").textContent =
"Connected - Live streaming";
appendLog("success", `Connected to ${appName} logs`);
};
ws.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
appendLog("log", log.MESSAGE || event.data, log);
} catch (e) {
appendLog("log", event.data);
}
};
ws.onerror = (error) => {
appendLog("error", "WebSocket error occurred");
document.getElementById("connection-status").textContent =
"Connection error";
};
ws.onclose = () => {
appendLog("warning", `Disconnected from ${appName}`);
document.getElementById("connection-status").textContent =
"Disconnected";
};
}
function appendLog(type, message, logData = null) {
const logContainer = document.getElementById("log-container");
const logEntry = document.createElement("div");
logEntry.className = "log-line";
const timestamp = new Date()
.toISOString()
.split("T")[1]
.slice(0, 12);
let icon = "●";
let color = "text-white";
if (type === "error") {
icon = "✖";
color = "text-red-400";
} else if (type === "warning") {
icon = "⚠";
color = "text-yellow-400";
} else if (type === "success") {
icon = "✓";
color = "text-green-400";
} else if (logData) {
// Parse log priority
const priority = logData.PRIORITY || "6";
if (priority <= "3") {
icon = "✖";
color = "text-red-400";
} else if (priority === "4") {
icon = "⚠";
color = "text-yellow-400";
} else {
icon = "●";
color = "text-blue-400";
}
}
logEntry.innerHTML = `
<span class="text-[#9dabb9] opacity-50">[${timestamp}]</span>
<span class="${color}">${icon}</span>
<span class="${color}">${escapeHtml(message)}</span>
`;
logContainer.appendChild(logEntry);
// Auto-scroll
if (autoScroll) {
const terminal = document.getElementById("log-terminal");
terminal.scrollTop = terminal.scrollHeight;
}
// Limit to last 1000 lines
while (logContainer.children.length > 1000) {
logContainer.removeChild(logContainer.firstChild);
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
const btn = document.getElementById("auto-scroll-btn");
btn.innerHTML = `
<span class="material-symbols-outlined text-[18px]">swap_vert</span>
Auto-scroll: ${autoScroll ? "ON" : "OFF"}
`;
if (autoScroll) {
btn.classList.add(
"bg-primary/20",
"border-primary/30",
"text-primary",
);
btn.classList.remove(
"bg-[#1c2730]",
"border-[#283039]",
"text-[#9dabb9]",
);
} else {
btn.classList.remove(
"bg-primary/20",
"border-primary/30",
"text-primary",
);
btn.classList.add(
"bg-[#1c2730]",
"border-[#283039]",
"text-[#9dabb9]",
);
}
}
function clearLogViewer() {
const logContainer = document.getElementById("log-container");
logContainer.innerHTML = `
<div class="text-[#9dabb9]">
<span class="text-blue-400"></span> Log viewer cleared
</div>
`;
}
// Load apps on page load
document.addEventListener("DOMContentLoaded", loadApps);
// Cleanup on page unload
window.addEventListener("beforeunload", () => {
if (ws) {
ws.close();
}
});
</script>
</body>
</html>