implementacion completa del sistema de monitorizacion y dashboard

This commit is contained in:
Trabajo
2026-04-29 13:01:57 +02:00
parent 50268ca321
commit b5db4fcfff
7 changed files with 239 additions and 9 deletions

6
.gitignore vendored
View File

@@ -136,3 +136,9 @@ dist
.yarn/install-state.gz
.pnp.*
# Cursor (AI editor) local state
.cursor/
# JetBrains IDE local state
.idea/

View File

@@ -1,2 +1,86 @@
# sf-monitorizacion-health
Aplicación Node.js/Express para monitorizar el endpoint de salud (`url_health`) de un conjunto de proyectos y mostrar un dashboard con el **último estado** registrado en PostgreSQL.
## Qué hace
1. Lee todos los registros de `proyectos`.
2. Para cada proyecto hace un `GET` a `url_health` con un `timeout` de 5s.
3. Guarda un registro en `estados` con:
- `OK` si la respuesta HTTP es `200`
- `ERROR` si la respuesta no es `200` (o cualquier error distinto)
- `TIMEOUT` si el error es `ECONNABORTED`
4. El dashboard (`GET /`) muestra, por proyecto, el **último** registro de `estados`.
El job se ejecuta de forma periódica (cada 60s) en bucle recursivo para evitar solapamientos.
## Requisitos
- Node.js 18+ (recomendado)
- PostgreSQL
## Configuración
La app lee estas variables de entorno (consultadas desde `src/config/postgreConfig.ts`):
- `DB_HOST`
- `DB_PORT`
- `DB_USER`
- `DB_PASSWORD`
- `DB_NAME`
Ejemplo `.env`:
```env
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=sf_monitorizacion_health
```
## Base de datos
La migración `deployment/database/migrations/001_init.sql` crea:
- `proyectos`
- tipo ENUM `tipo_estado` con `OK`, `ERROR`, `TIMEOUT`
- `estados`
- índice para consultar rápido el último estado por proyecto
> Nota: la migración hace `DROP TABLE IF EXISTS`, así que destruye los datos actuales.
## Cómo ejecutar
1. Instala dependencias:
- `npm install`
2. Asegúrate de tener creada la BD (ejecuta `deployment/database/migrations/001_init.sql`).
3. Ejecuta en modo desarrollo:
- `npm run dev`
La app levanta el servidor en `http://localhost:3000`.
## Dashboard
Ruta:
- `GET /` : renderiza `src/views/index.ejs`
En la tabla se muestra:
- Nombre del proyecto (`proyectos.nombre`)
- URL (`proyectos.url_health`)
- Estado (`estados.estado_codigo` del último registro)
- “Última comprobación” (`estados.fecha`)
Si un proyecto todavía no tiene estados registrados, el dashboard muestra `PENDIENTE`.
## Añadir proyectos
Inserta en `proyectos`:
```sql
INSERT INTO proyectos (nombre, url_health)
VALUES ('Mi proyecto', 'https://ejemplo.com/health');
```
Los estados empezarán a generarse automáticamente cuando arranque el servidor y empiece el ciclo de checks.

View File

@@ -0,0 +1,28 @@
-- 1. Comprobando nombres de tablas, y creando tipo de forma seguro
DROP TABLE IF EXISTS estados;
DROP TABLE IF EXISTS proyectos;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'tipo_estado') THEN
CREATE TYPE tipo_estado AS ENUM ('OK', 'ERROR', 'TIMEOUT');
END IF;
END
$$;
-- 2. Tabla de proyectos
CREATE TABLE proyectos (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
nombre VARCHAR(100) NOT NULL,
url_health TEXT NOT NULL
);
-- 3. Tabla de estados (con BIGINT y ENUM)
CREATE TABLE estados (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
proyecto_id INT REFERENCES proyectos(id),
estado_codigo tipo_estado NOT NULL,
fecha TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 4. Índice para agilizar la búsqueda de los últimos estados
CREATE INDEX idx_estados_proyecto_fecha ON estados (proyecto_id, fecha DESC);

View File

@@ -6,7 +6,7 @@ import path from 'path';
export class SimController {
constructor(private usecases: SimUsecases) {}
renderDashboard = async (req: Request, res: Response, next: NextFunction) => {
renderDashboard = async (_req: Request, res: Response, next: NextFunction) => {
try {
const proyectos = await this.usecases.getDashboardData();
const filePath = path.join(process.cwd(), 'src/views/index.ejs');

View File

@@ -1,15 +1,52 @@
import express, { Request, Response, NextFunction } from "express";
import express from "express";
import type { Request, Response, NextFunction } from "express";
import path from "path";
import { createRouter } from "./infrastructure/sim-routes-http.js";
import { SimUsecases } from "./application/sim-usecases.js";
import { pool } from "./config/postgreConfig.js";
import { AppError } from "./domain/common.js";
const app = express();
const port = 3000;
app.use(express.json());
app.get("/", (req: Request, res: Response) => {
res.send({ message: "Hello World" });
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).send("Algo salió mal");
app.use(express.json());
app.set("view engine", "ejs");
app.set("views", path.join(process.cwd(), "src/views"));
const monitorJob = new SimUsecases(pool);
app.use("/", createRouter(monitorJob));
const poll = async () => {
try {
await monitorJob.checkAllProjectsHealth();
} catch (err) {
console.error(`[Background Job Error]: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setTimeout(poll, 300000);
}
};
void poll();
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
const status = err instanceof AppError ? err.status : 500;
const details = err instanceof AppError ? err.details : {};
console.error(`[Error ${status}][${err.name}]: ${err.message}`, details);
if (req.accepts("html")) {
return res.status(status).render("error", {
status,
message: err.message
});
}
res.status(status).json({
error: err.message,
status
});
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
});

19
src/views/error.ejs Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }
h1 { color: #c62828; }
a { color: #1565c0; }
</style>
</head>
<body>
<%# Vista genérica para captura de rrores mediante el middleware de Express %>
<h1>Error</h1>
<p><%= message %></p>
<a href="/">Volver al inicio</a>
</body>
</html>

56
src/views/index.ejs Normal file
View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monitor de Salud</title>
<style>
/* Estilos base para una interfaz limpia y centrada */
body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #f9f9f9; }
h1 { color: #333; }
table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
td { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #f4f4f4; font-weight: bold; }
tr:hover { background: #fafafa; }
/* Estados visuales basados en la respuesta del monitor */
.OK { color: #2e7d32; font-weight: bold; }
.ERROR { color: #c62828; font-weight: bold; }
.TIMEOUT { color: #e65100; font-weight: bold; }
</style>
</head>
<body>
<h1>Monitor de Salud</h1>
<table>
<thead>
<tr>
<th>Proyecto</th>
<th>URL</th>
<th>Estado</th>
<th>Última comprobación</th>
</tr>
</thead>
<tbody>
<%# Iteración de la lista de proyectos inyectada de desde el controlador. %>
<% proyectos.forEach(function(p) { %>
<tr>
<td><%= p.nombre %></td>
<td>
<a href="<%= p.url_health %>" target="_blank" title="<%= p.url_health %>">
<%= p.url_health %>
</a>
</td>
<%# La clase CSS coincide con el estado_codigo para aplicar el color correspondiente %>
<td class="<%= p.estado_codigo || 'PENDIENTE' %>"><%= p.estado_codigo || 'PENDIENTE' %>
</td>
<td>
<%# Formateo de fecha en servidor antes de renderizar %>
<%= p.fecha ? new Date(p.fecha).toLocaleString('es-ES') : '—' %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</body>
</html>