Implementación de logica de monitorización y scheduler

This commit is contained in:
Trabajo
2026-05-05 09:49:23 +02:00
parent 2480581993
commit 818a1899b0
3 changed files with 195 additions and 39 deletions

View File

@@ -0,0 +1,53 @@
import { AppError } from '../domain/common.js';
import { MonitorUsecases } from './monitor-usecases.js';
export class MonitorScheduler {
private timeout?: ReturnType<typeof setTimeout> | 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<void> {
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();
}
}
}

View File

@@ -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<void> {
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<void> {
try {
const { rows: projects } = await this.db.query<Proyecto>('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<Proyecto[]> {
// 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<Proyecto>(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<void> {
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<void> {
const project = await this.repository.getProjectById(projectId);
if (!project) {
throw new AppError('Proyecto no encontrado', 404);
}
await this.checkProjectAndSaveStatus(project);
}
}

View File

@@ -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<Proyecto[]> {
try {
const { rows } = await this.db.query<Proyecto>('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<Proyecto | undefined> {
try {
const { rows } = await this.db.query<Proyecto>(
'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<void> {
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<Proyecto[]> {
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<Proyecto>(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<void> {
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)
});
}
}
}