From 818a1899b018efde40678bf7a67be708925617a3 Mon Sep 17 00:00:00 2001 From: Trabajo Date: Tue, 5 May 2026 09:49:23 +0200 Subject: [PATCH] =?UTF-8?q?Implementaci=C3=B3n=20de=20logica=20de=20monito?= =?UTF-8?q?rizaci=C3=B3n=20y=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/application/monitor-scheduler.ts | 53 +++++++++++++++ src/application/monitor-usecases.ts | 98 +++++++++++++++++----------- src/repository/monitor-repository.ts | 83 +++++++++++++++++++++++ 3 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 src/application/monitor-scheduler.ts create mode 100644 src/repository/monitor-repository.ts 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 index e098c22..c424546 100644 --- a/src/application/monitor-usecases.ts +++ b/src/application/monitor-usecases.ts @@ -1,57 +1,77 @@ import axios from 'axios'; -import { type Pool } from 'pg'; -import { type Proyecto, RepositoryError } from '../domain/common.js'; +import { AppError, type Proyecto, RepositoryError } from '../domain/common.js'; +import { MonitorRepository } from '../repository/monitor-repository.js'; export class MonitorUsecases { - constructor(private db: Pool) {} + 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 { rows: projects } = await this.db.query('SELECT * FROM proyectos'); + const projects = await this.repository.getProjects(); + const checks = projects.map((project) => this.checkProjectAndSaveStatus(project)); - 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) }); + 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; + return await this.repository.getDashboardData(); } catch (err) { - throw new RepositoryError('Error al recuperar datos del dashboard', { cause: err instanceof Error ? err.message : String(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/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) + }); + } + } +}