diff --git a/README.md b/README.md index 3957623..4782f5c 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ Es el núcleo de la aplicación. Configura un servidor Express que gestiona toda ``` * **`GET /healthchecker`** - * **Descripción**: Ejecuta un script de monitoreo en Python (`scripts/health_checker.py`) y devuelve un reporte detallado del estado de los servicios definidos en `data/sites.json`. - * **Lógica**: Utiliza `child_process.exec` de Node.js para invocar el script de Python. Captura la salida JSON del script y la sirve como respuesta. Es ideal para tableros de monitoreo o webhooks. + * **Descripción**: Ejecuta un monitor de salud escrito en Node.js (`scripts/health_checker.js`) y devuelve un reporte detallado del estado de los servicios definidos en `data/sites.json`. + * **Lógica**: Importa el módulo de chequeo desde `server.js`, corre todas las verificaciones (HTTP simples, endpoints dedicados y statuspage.io) y retorna el JSON resultante, ideal para tableros o webhooks. * **Respuesta de Ejemplo (truncada)**: ```json { @@ -114,9 +114,9 @@ Este directorio centraliza todos los datos que la aplicación necesita para func Contiene la lógica de negocio más compleja en forma de scripts. -* **`health_checker.py`**: - * **Lenguaje**: Python 3. - * **Dependencias**: `requests`. +* **`health_checker.js`**: + * **Lenguaje**: Node.js (requiere Node 18+ con `fetch` nativo). + * **Dependencias**: ninguna adicional (usa APIs nativas). * **Lógica**: 1. Carga la lista de sitios desde `../data/sites.json`. 2. Itera sobre cada servicio y realiza diferentes tipos de verificaciones: diff --git a/scripts/health_checker.js b/scripts/health_checker.js new file mode 100644 index 0000000..b26a0f2 --- /dev/null +++ b/scripts/health_checker.js @@ -0,0 +1,220 @@ +const fs = require("fs/promises"); +const path = require("path"); + +const STATUSPAGE_SERVICES = new Set(["openai", "canva", "cloudflare"]); +const DEFAULT_TIMEOUT_MS = 10_000; + +const getWebhookUrls = () => { + const value = process.env.WEBHOOK_URLS || ""; + return value + .split(",") + .map((url) => url.trim()) + .filter(Boolean); +}; + +const fetchWithTimeout = async (url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + return response; + } finally { + clearTimeout(id); + } +}; + +const checkUrl = async (url) => { + try { + const response = await fetchWithTimeout(url, { + headers: { "User-Agent": "HealthCheckMonitor/1.0" }, + }); + return response.status; + } catch { + return 0; + } +}; + +const checkVpsHealthEndpoint = async (url) => { + try { + const response = await fetchWithTimeout(url); + if (response.status !== 200) { + return `🔴 Caído (Endpoint status: ${response.status})`; + } + const data = await response.json(); + const alive = data?.checks?.vps_ping?.alive; + if (alive) { + return "🟢 OK (VPS Reachable)"; + } + return "🔴 Caído (VPS reporta 'alive': false)"; + } catch (error) { + return `🔴 Error Conexión (${error.message})`; + } +}; + +const checkFormbricksHealth = async (url) => { + try { + const response = await fetchWithTimeout(url, {}, 8_000); + if (response.status !== 200) { + return `🔴 Caído (Código: ${response.status})`; + } + const data = await response.json().catch(() => null); + if (data?.status === "ok") { + return "🟢 OK (API Health: ok)"; + } + if (data?.status) { + return `🟡 Advertencia (${data.status})`; + } + return "🟡 Advertencia (No JSON)"; + } catch { + return "🔴 Caído (Error red)"; + } +}; + +const getStatusPageStatus = async (baseUrl) => { + const url = `${baseUrl.replace(/\/$/, "")}/api/v2/summary.json`; + try { + const response = await fetchWithTimeout(url, {}, 8_000); + if (response.status !== 200) { + return `🔴 Caído (${response.status})`; + } + const data = await response.json(); + const indicator = data?.status?.indicator; + const description = data?.status?.description; + if (indicator === "none") { + return `🟢 OK (${description})`; + } + return `🟡 Advertencia (${description})`; + } catch { + return "🔴 Error verificación"; + } +}; + +const getGeminiStatus = async (displayUrl) => { + const incidentsUrl = "https://status.cloud.google.com/incidents.json"; + try { + const response = await fetchWithTimeout(incidentsUrl, {}, 8_000); + if (response.status === 200) { + const incidents = await response.json(); + const active = incidents.filter((incident) => { + if (incident.end) return false; + const serviceName = (incident.service_name || "").toLowerCase(); + return ( + serviceName.includes("gemini") || + serviceName.includes("vertex") || + serviceName.includes("generative") + ); + }); + if (active.length === 0) { + return "🟢 OK (Sin incidentes en Google AI)"; + } + return `🟡 Advertencia (${active.length} incidentes activos)`; + } + const backupStatus = humanState(await checkUrl(displayUrl)); + return backupStatus; + } catch { + return "🔴 Error de conexión"; + } +}; + +const humanState = (code) => { + if (code === 200) return `🟢 OK (${code})`; + if ([301, 302, 307, 308].includes(code)) return `🟢 OK (Redirección ${code})`; + if ([401, 403, 404].includes(code)) return `🟡 Advertencia (${code})`; + return `🔴 Caído (${code})`; +}; + +const buildSection = async (dictionary) => { + const output = {}; + + for (const [name, urlOrIp] of Object.entries(dictionary)) { + let statusMessage; + + if (name === "vps_soul23") { + statusMessage = await checkVpsHealthEndpoint(urlOrIp); + output[`${name}_status`] = statusMessage; + output[`${name}_state`] = statusMessage; + } else if (STATUSPAGE_SERVICES.has(name)) { + statusMessage = await getStatusPageStatus(urlOrIp); + output[`${name}_status`] = statusMessage; + output[`${name}_state`] = statusMessage; + } else if (name === "google_gemini") { + statusMessage = await getGeminiStatus(urlOrIp); + output[`${name}_status`] = statusMessage; + output[`${name}_state`] = statusMessage; + } else if (name === "formbricks") { + statusMessage = await checkFormbricksHealth(urlOrIp); + output[`${name}_status`] = statusMessage; + output[`${name}_state`] = statusMessage; + } else { + const statusCode = await checkUrl(urlOrIp); + output[`${name}_status`] = statusCode; + output[`${name}_state`] = humanState(statusCode); + } + + output[`${name}_url`] = urlOrIp; + } + + return output; +}; + +const postWebhooks = async (payload) => { + const urls = getWebhookUrls(); + if (!urls.length) return; + + await Promise.all( + urls.map(async (url) => { + try { + await fetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + 10_000 + ); + } catch { + // Silenciar errores individuales de webhook para no romper el resto del proceso + } + }) + ); +}; + +const runHealthChecker = async () => { + const start = Date.now(); + const sitesPath = path.join(__dirname, "..", "data", "sites.json"); + const raw = await fs.readFile(sitesPath, "utf8"); + const sites = JSON.parse(raw); + + const result = { + timestamp: new Date().toISOString(), + internos: await buildSection(sites.internos || {}), + empresa: await buildSection(sites.sitios_empresa || {}), + externos: await buildSection(sites.externos || {}), + }; + + const duration = (Date.now() - start) / 1000; + result.execution_time_seconds = Number(duration.toFixed(2)); + + await postWebhooks(result); + return result; +}; + +module.exports = { runHealthChecker }; + +if (require.main === module) { + runHealthChecker() + .then((data) => { + console.log(JSON.stringify(data, null, 4)); + }) + .catch((error) => { + console.error( + JSON.stringify( + { error: "Health checker failed", details: error.message }, + null, + 4 + ) + ); + process.exit(1); + }); +} diff --git a/server.js b/server.js index a30a597..9c81fc1 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ const express = require("express"); const path = require("path"); const fs = require("fs"); const { exec } = require("child_process"); +const { runHealthChecker } = require("./scripts/health_checker"); const app = express(); const port = process.env.PORT || 3001; @@ -17,28 +18,19 @@ app.use(express.static(rootDir, { index: "index.html" })); /** * @route GET /healthchecker - * @description Ejecuta un script de Python para un chequeo de salud avanzado. - * Invoca el script `scripts/health_checker.py`, que monitorea múltiples servicios - * y devuelve un reporte detallado en formato JSON. + * @description Ejecuta un chequeo de salud avanzado implementado en Node.js. + * Lee `data/sites.json`, consulta los servicios definidos y devuelve un reporte JSON. * @returns {object} Un objeto JSON con el estado de los servicios monitoreados. - * @throws 500 - Si el script de Python falla o su salida no es un JSON válido. + * @throws 500 - Si ocurre algún error durante la ejecución del chequeo. */ -app.get("/healthchecker", (req, res) => { - const pythonScriptPath = path.join(rootDir, "scripts", "health_checker.py"); - - exec(`python3 ${pythonScriptPath}`, (error, stdout, stderr) => { - if (error) { - console.error(`Error al ejecutar health_checker.py: ${stderr}`); - return res.status(500).json({ error: "No se pudo ejecutar el chequeo de salud" }); - } - try { - const healthData = JSON.parse(stdout); - res.status(200).json(healthData); - } catch (parseErr) { - console.error(`Error al parsear la salida del chequeo de salud: ${parseErr}`); - res.status(500).json({ error: "La respuesta del chequeo de salud no es un JSON válido" }); - } - }); +app.get("/healthchecker", async (req, res) => { + try { + const healthData = await runHealthChecker(); + res.status(200).json(healthData); + } catch (error) { + console.error("Error al ejecutar el health checker:", error); + res.status(500).json({ error: "No se pudo ejecutar el chequeo de salud" }); + } }); /**