Implementación de logica de monitorización y scheduler
This commit is contained in:
53
src/application/monitor-scheduler.ts
Normal file
53
src/application/monitor-scheduler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
src/repository/monitor-repository.ts
Normal file
83
src/repository/monitor-repository.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user