implementacion completa del sistema de monitorizacion y dashboard
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -136,3 +136,9 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Cursor (AI editor) local state
|
||||
.cursor/
|
||||
|
||||
# JetBrains IDE local state
|
||||
.idea/
|
||||
|
||||
|
||||
84
README.md
84
README.md
@@ -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.
|
||||
|
||||
|
||||
28
deployment/database/migrations/001_init.sql
Normal file
28
deployment/database/migrations/001_init.sql
Normal 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);
|
||||
@@ -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');
|
||||
|
||||
53
src/index.ts
53
src/index.ts
@@ -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
19
src/views/error.ejs
Normal 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
56
src/views/index.ejs
Normal 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>
|
||||
Reference in New Issue
Block a user