diff --git a/.gitignore b/.gitignore index 9fe585b..0ab4d18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# TypeScript build output +dist/ + # ---> Node # Logs logs @@ -142,3 +145,4 @@ dist # JetBrains IDE local state .idea/ + diff --git a/README.md b/README.md index 16b7d6d..9304e93 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# sf-monitorizacion-health +# 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. @@ -12,16 +12,17 @@ Aplicación Node.js/Express para monitorizar el endpoint de salud (`url_health`) - `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. +El job se ejecuta de forma periódica (configurable desde la interfaz, por defecto 5 min) en bucle recursivo para evitar solapamientos. ## Requisitos - Node.js 18+ (recomendado) +- TypeScript - PostgreSQL ## Configuración -La app lee estas variables de entorno (consultadas desde `src/config/postgreConfig.ts`): +La app lee estas variables de entorno (consultadas desde `src/config/postgres-config.ts`): - `DB_HOST` - `DB_PORT` @@ -43,27 +44,26 @@ DB_NAME=sf_monitorizacion_health La migración `deployment/database/migrations/001_init.sql` crea: -- `proyectos` -- tipo ENUM `tipo_estado` con `OK`, `ERROR`, `TIMEOUT` +- `proyectos` (con restricción UNIQUE en nombre y URL, y límite de 100 caracteres para el nombre) - `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. +> Nota: La migración utiliza `CREATE TABLE IF NOT EXISTS`. ## 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` +3. Ejecuta los scripts disponibles: + - `npm run dev`: Inicia en modo desarrollo con `tsx`. + - `npm run build`: Compila el código TypeScript. La app levanta el servidor en `http://localhost:3000`. ## Dashboard Ruta: -- `GET /` : renderiza `src/views/index.ejs` +- `GET /` : renderiza la vista principal. En la tabla se muestra: - Nombre del proyecto (`proyectos.nombre`) @@ -75,7 +75,7 @@ Si un proyecto todavía no tiene estados registrados, el dashboard muestra `PEND ## Añadir proyectos -Inserta en `proyectos`: +Se pueden añadir desde la interfaz en `/nuevo` o mediante SQL: ```sql INSERT INTO proyectos (nombre, url_health) @@ -83,4 +83,3 @@ 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. - diff --git a/package.json b/package.json index c4d2d94..2815a6f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "tsx src/index.ts", + "build": "tsc", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { diff --git a/src/application/monitor-controller.ts b/src/application/monitor-controller.ts new file mode 100644 index 0000000..cc7de57 --- /dev/null +++ b/src/application/monitor-controller.ts @@ -0,0 +1,74 @@ +import { type Request, type Response, type NextFunction } from 'express'; +import { MonitorScheduler } from './monitor-scheduler.js'; +import { MonitorUsecases } from './monitor-usecases.js'; +import { AppError } from '../domain/common.js'; + +export class MonitorController { + constructor( + private usecases: MonitorUsecases, + private scheduler: MonitorScheduler + ) {} + + renderDashboard = async (_req: Request, res: Response, next: NextFunction) => { + try { + const proyectos = await this.usecases.getDashboardData(); + res.render('index', { + proyectos, + monitorIntervalSeconds: this.scheduler.getIntervalSeconds() + }); + } catch (err) { + next(err); + } + } + + renderAddProject = async (_req: Request, res: Response, next: NextFunction) => { + try { + res.render('add-project'); + } catch (err) { + next(err); + } + } + + saveProject = async (req: Request, res: Response, next: NextFunction) => { + try { + const { nombre, url_health } = req.body; + await this.usecases.createNewProject(nombre, url_health); + res.redirect('/'); + } catch (err) { + next(err); + } + } + + checkAllProjects = async (_req: Request, res: Response, next: NextFunction) => { + try { + await this.usecases.checkAllProjectsHealth(); + res.sendStatus(204); + } catch (err) { + next(err); + } + } + + checkProject = async (req: Request, res: Response, next: NextFunction) => { + try { + const projectId = Number(req.params['id']); + if (!Number.isInteger(projectId) || projectId <= 0) { + throw new AppError('Id de proyecto invalido', 400); + } + + await this.usecases.checkProjectHealth(projectId); + res.sendStatus(204); + } catch (err) { + next(err); + } + } + + updateMonitorInterval = async (req: Request, res: Response, next: NextFunction) => { + try { + const seconds = Number(req.body.seconds); + this.scheduler.setIntervalSeconds(seconds); + res.json({ seconds: this.scheduler.getIntervalSeconds() }); + } catch (err) { + next(err); + } + } +} diff --git a/src/application/monitor-scheduler.ts b/src/application/monitor-scheduler.ts new file mode 100644 index 0000000..b394d52 --- /dev/null +++ b/src/application/monitor-scheduler.ts @@ -0,0 +1,53 @@ +import { AppError } from '../domain/common.js'; +import { MonitorUsecases } from './monitor-usecases.js'; + +export class MonitorScheduler { + private timeout?: ReturnType | undefined; + + constructor( + private usecases: MonitorUsecases, + private intervalMs: number + ) {} + + start(): void { + void this.runAndScheduleNext(); + } + + getIntervalSeconds(): number { + return Math.floor(this.intervalMs / 1000); + } + + setIntervalSeconds(seconds: number): void { + if (!Number.isInteger(seconds) || seconds < 10 || seconds > 3600) { + throw new AppError('El intervalo debe estar entre 10 y 3600 segundos', 400); + } + + this.intervalMs = seconds * 1000; + this.scheduleNext(); + } + + private scheduleNext(): void { + if (this.timeout) { + clearTimeout(this.timeout); + } + + this.timeout = setTimeout(() => { + void this.runAndScheduleNext(); + }, this.intervalMs); + } + + private async runAndScheduleNext(): Promise { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = undefined; + } + + try { + await this.usecases.checkAllProjectsHealth(); + } catch (err) { + console.error(`[Background Job Error]: ${err instanceof Error ? err.message : String(err)}`); + } finally { + this.scheduleNext(); + } + } +} diff --git a/src/application/monitor-usecases.ts b/src/application/monitor-usecases.ts new file mode 100644 index 0000000..c424546 --- /dev/null +++ b/src/application/monitor-usecases.ts @@ -0,0 +1,77 @@ +import axios from 'axios'; +import { AppError, type Proyecto, RepositoryError } from '../domain/common.js'; +import { MonitorRepository } from '../repository/monitor-repository.js'; + +export class MonitorUsecases { + constructor(private repository: MonitorRepository) {} + + private async checkProjectAndSaveStatus(project: Proyecto): Promise { + let status: Proyecto['estado_codigo'] = 'OK'; + try { + await axios.get(project.url_health, { timeout: 5000, validateStatus: (s) => s === 200 }); + } catch (e) { + const axiosError = e as { code?: string }; + status = axiosError.code === 'ECONNABORTED' ? 'TIMEOUT' : 'ERROR'; + } + + await this.repository.saveProjectStatus(project.id, status); + } + + async checkAllProjectsHealth(): Promise { + try { + const projects = await this.repository.getProjects(); + const checks = projects.map((project) => this.checkProjectAndSaveStatus(project)); + + await Promise.allSettled(checks); + } catch (err) { + throw new RepositoryError('Fallo masivo en el chequeo de salud', { + cause: err instanceof Error ? err.message : String(err) + }); + } + } + + async getDashboardData(): Promise { + try { + return await this.repository.getDashboardData(); + } catch (err) { + throw new RepositoryError('Error al recuperar datos del dashboard', { + cause: err instanceof Error ? err.message : String(err) + }); + } + } + + async createNewProject(nombre: string, urlHealth: string): Promise { + const trimmedNombre = typeof nombre === 'string' ? nombre.trim() : ''; + const trimmedUrl = typeof urlHealth === 'string' ? urlHealth.trim() : ''; + + if (trimmedNombre === '') { + throw new AppError('El nombre del proyecto es obligatorio', 400); + } + + if (trimmedNombre.length > 100) { + throw new AppError('El nombre del proyecto no puede superar los 100 caracteres', 400); + } + + if (trimmedUrl === '') { + throw new AppError('La URL de health check es obligatoria', 400); + } + + try { + new URL(trimmedUrl); + } catch { + throw new AppError('La URL de health check no es valida', 400); + } + + await this.repository.addProject(trimmedNombre, trimmedUrl); + } + + async checkProjectHealth(projectId: number): Promise { + const project = await this.repository.getProjectById(projectId); + if (!project) { + throw new AppError('Proyecto no encontrado', 404); + } + + await this.checkProjectAndSaveStatus(project); + } +} + diff --git a/src/application/sim-controller.ts b/src/application/sim-controller.ts deleted file mode 100644 index 343da21..0000000 --- a/src/application/sim-controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type Request, type Response, type NextFunction } from 'express'; -import { SimUsecases } from './sim-usecases.js'; -import ejs from 'ejs'; -import path from 'path'; - -export class SimController { - constructor(private usecases: SimUsecases) {} - - 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'); - ejs.renderFile(filePath, { proyectos }, (err, str) => { - if (err) return next(err); - res.send(str); - }); - } catch (err) { - next(err); - } - } -} diff --git a/src/application/sim-usecases.ts b/src/application/sim-usecases.ts deleted file mode 100644 index 936cd0f..0000000 --- a/src/application/sim-usecases.ts +++ /dev/null @@ -1,57 +0,0 @@ -import axios from 'axios'; -import { type Pool } from 'pg'; -import { type Proyecto, RepositoryError } from '../domain/common.js'; - -export class SimUsecases { - constructor(private db: Pool) {} - - async checkAllProjectsHealth(): Promise { - try { - const { rows: projects } = await this.db.query('SELECT * FROM proyectos'); - - const checks = projects.map(async (p) => { - let status: Proyecto['estado_codigo'] = 'OK'; - try { - // Solo 200 es válido: cualquier otro código (301, 403, 5xx) indica un problema real del servicio. - await axios.get(p.url_health, { timeout: 5000, validateStatus: (s) => s === 200 }); - } catch (e) { - const axiosError = e as { code?: string }; - status = axiosError.code === 'ECONNABORTED' ? 'TIMEOUT' : 'ERROR'; - } - - return this.db.query( - 'INSERT INTO estados (proyecto_id, estado_codigo, fecha) VALUES ($1, $2, NOW())', - [p.id, status] - ); - }); - - // allSettled en lugar de all: un fallo al insertar en BD no debe cancelar el resto de checks. - await Promise.allSettled(checks); - } catch (err) { - throw new RepositoryError('Fallo masivo en el chequeo de salud', { cause: err instanceof Error ? err.message : String(err) }); - } - } - - async getDashboardData(): Promise { - // LEFT JOIN LATERAL nos deja calcular "la última fila de estados" para cada proyecto. - // Usamos LEFT JOIN para no perder proyectos que todavía no tienen estados registrados. - const query = ` - SELECT p.id, p.nombre, p.url_health, e.estado_codigo, e.fecha - FROM proyectos p - LEFT JOIN LATERAL ( - SELECT estado_codigo, fecha - FROM estados - WHERE proyecto_id = p.id - ORDER BY fecha DESC - LIMIT 1 - ) e ON true - ORDER BY p.nombre ASC; - `; - try { - const { rows } = await this.db.query(query); - return rows; - } catch (err) { - throw new RepositoryError('Error al recuperar datos del dashboard', { cause: err instanceof Error ? err.message : String(err) }); - } - } -} diff --git a/src/config/postgreConfig.ts b/src/config/postgres-config.ts similarity index 100% rename from src/config/postgreConfig.ts rename to src/config/postgres-config.ts diff --git a/src/index.ts b/src/index.ts index 55fd278..fcb64d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,29 @@ 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 { createRouter } from "./infrastructure/monitor-routes-http.js"; +import { MonitorScheduler } from "./application/monitor-scheduler.js"; +import { MonitorUsecases } from "./application/monitor-usecases.js"; +import { MonitorRepository } from "./repository/monitor-repository.js"; +import { pool } from "./config/postgres-config.js"; import { AppError } from "./domain/common.js"; const app = express(); -const port = 3000; +const port = Number(process.env.PORT) || 3000; app.use(express.json()); +app.use(express.urlencoded({ extended: false })); app.set("view engine", "ejs"); app.set("views", path.join(process.cwd(), "src/views")); -const monitorJob = new SimUsecases(pool); +const monitorRepository = new MonitorRepository(pool); +const monitorJob = new MonitorUsecases(monitorRepository); +const monitorScheduler = new MonitorScheduler(monitorJob, 300000); -app.use("/", createRouter(monitorJob)); +app.use("/", createRouter(monitorJob, monitorScheduler)); -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(); +monitorScheduler.start(); app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { const status = err instanceof AppError ? err.status : 500; @@ -49,4 +44,4 @@ app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { app.listen(port, () => { console.log(`Server is running on port ${port}`); -}); \ No newline at end of file +}); diff --git a/src/infrastructure/monitor-routes-http.ts b/src/infrastructure/monitor-routes-http.ts new file mode 100644 index 0000000..bfdc44c --- /dev/null +++ b/src/infrastructure/monitor-routes-http.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { MonitorController } from '../application/monitor-controller.js'; +import { MonitorScheduler } from '../application/monitor-scheduler.js'; +import { MonitorUsecases } from '../application/monitor-usecases.js'; + +export function createRouter(usecases: MonitorUsecases, scheduler: MonitorScheduler): Router { + const router = Router(); + const controller = new MonitorController(usecases, scheduler); + + router.get('/', controller.renderDashboard); + router.get('/nuevo', controller.renderAddProject); + router.post('/proyectos', controller.saveProject); + router.post('/check-all', controller.checkAllProjects); + router.post('/check/:id', controller.checkProject); + router.post('/monitor-interval', controller.updateMonitorInterval); + + return router; +} diff --git a/src/infrastructure/sim-routes-http.ts b/src/infrastructure/sim-routes-http.ts deleted file mode 100644 index 1764b80..0000000 --- a/src/infrastructure/sim-routes-http.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router } from 'express'; -import { SimController } from '../application/sim-controller.js'; -import { SimUsecases } from '../application/sim-usecases.js'; - -export function createRouter(usecases: SimUsecases): Router { - const router = Router(); - const controller = new SimController(usecases); - router.get('/', controller.renderDashboard); - return router; -} \ No newline at end of file diff --git a/src/repository/monitor-repository.ts b/src/repository/monitor-repository.ts new file mode 100644 index 0000000..b5d7f29 --- /dev/null +++ b/src/repository/monitor-repository.ts @@ -0,0 +1,83 @@ +import { type Pool } from 'pg'; +import { type Proyecto, RepositoryError } from '../domain/common.js'; + +export class MonitorRepository { + constructor(private db: Pool) {} + + async getProjects(): Promise { + try { + const { rows } = await this.db.query('SELECT * FROM proyectos'); + return rows; + } catch (err) { + throw new RepositoryError('Error al recuperar proyectos', { + cause: err instanceof Error ? err.message : String(err) + }); + } + } + + async getProjectById(projectId: number): Promise { + try { + const { rows } = await this.db.query( + 'SELECT * FROM proyectos WHERE id = $1', + [projectId] + ); + return rows[0]; + } catch (err) { + throw new RepositoryError('Error al recuperar el proyecto', { + projectId, + cause: err instanceof Error ? err.message : String(err) + }); + } + } + + async saveProjectStatus(proyectoId: number, status: Proyecto['estado_codigo']): Promise { + try { + await this.db.query( + 'INSERT INTO estados (proyecto_id, estado_codigo, fecha) VALUES ($1, $2, NOW())', + [proyectoId, status] + ); + } catch (err) { + throw new RepositoryError('Error al guardar estado del proyecto', { + proyectoId, + cause: err instanceof Error ? err.message : String(err) + }); + } + } + + async getDashboardData(): Promise { + const query = ` + SELECT p.id, p.nombre, p.url_health, e.estado_codigo, e.fecha + FROM proyectos p + LEFT JOIN LATERAL ( + SELECT estado_codigo, fecha + FROM estados + WHERE proyecto_id = p.id + ORDER BY fecha DESC + LIMIT 1 + ) e ON true + ORDER BY p.nombre ASC; + `; + + try { + const { rows } = await this.db.query(query); + return rows; + } catch (err) { + throw new RepositoryError('Error al recuperar datos del dashboard', { + cause: err instanceof Error ? err.message : String(err) + }); + } + } + + async addProject(nombre: string, url_health: string): Promise { + try { + await this.db.query( + 'INSERT INTO proyectos (nombre, url_health) VALUES ($1, $2)', + [nombre.trim(), url_health.trim()] + ); + } catch (err) { + throw new RepositoryError('Error al crear el proyecto', { + cause: err instanceof Error ? err.message : String(err) + }); + } + } +} diff --git a/src/views/add-project.ejs b/src/views/add-project.ejs new file mode 100644 index 0000000..da94afb --- /dev/null +++ b/src/views/add-project.ejs @@ -0,0 +1,33 @@ + + + + + Nuevo Proyecto + + + +
+

Añadir Proyecto

+
+ + + + + + +
+ Volver + +
+
+
+ + \ No newline at end of file diff --git a/src/views/error.ejs b/src/views/error.ejs index 3a180a1..2785ad8 100644 --- a/src/views/error.ejs +++ b/src/views/error.ejs @@ -11,7 +11,7 @@ - <%# Vista genérica para captura de rrores mediante el middleware de Express %> + <%# Vista genérica para captura de errores mediante el middleware de Express %>

Error

<%= message %>

Volver al inicio diff --git a/src/views/index.ejs b/src/views/index.ejs index 028e84b..af1db89 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -5,52 +5,189 @@ Monitor de Salud -

Monitor de Salud

- - - - - - - - - - - <%# Iteración de la lista de proyectos inyectada de desde el controlador. %> - <% proyectos.forEach(function(p) { %> - - - - <%# La clase CSS coincide con el estado_codigo para aplicar el color correspondiente %> - - - - <% }) %> - -
ProyectoURLEstadoÚltima comprobación
<%= p.nombre %> - - <%= p.url_health %> - - <%= p.estado_codigo || 'PENDIENTE' %> - - <%# Formateo de fecha en servidor antes de renderizar %> - <%= p.fecha ? new Date(p.fecha).toLocaleString('es-ES') : '—' %> -
+

Monitor de Salud

+ +
+ + +
+ + + segundos + + +
+
+ + + + + + + + + + + + + <% proyectos.forEach(function(p) { %> + + + + + + + + <% }) %> + +
ProyectoURLEstadoUltima comprobacionAcciones
<%= p.nombre %> + + <%= p.url_health %> + + + <%= p.estado_codigo || 'PENDIENTE' %> + + <%= p.fecha ? new Date(p.fecha).toLocaleString('es-ES') : '-' %> + + +
+ + diff --git a/tsconfig.json b/tsconfig.json index dda4b2c..6e15b92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,8 @@ // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { // File Layout - // "rootDir": "./src", - // "outDir": "./dist", + "rootDir": "./src", + "outDir": "./dist", // Environment Settings // See also https://aka.ms/tsconfig/module "module": "nodenext", @@ -37,3 +37,4 @@ "skipLibCheck": true, } } +