Merge pull request 'Panel interactivo de gestión y monitorización de proyectos' (#3) from feat/setup-and-config into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# TypeScript build output
|
||||
dist/
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
@@ -142,3 +145,4 @@ dist
|
||||
# JetBrains IDE local state
|
||||
.idea/
|
||||
|
||||
|
||||
|
||||
23
README.md
23
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.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
74
src/application/monitor-controller.ts
Normal file
74
src/application/monitor-controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/application/monitor-usecases.ts
Normal file
77
src/application/monitor-usecases.ts
Normal file
@@ -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<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 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<Proyecto[]> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
try {
|
||||
const { rows: projects } = await this.db.query<Proyecto>('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<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;
|
||||
} catch (err) {
|
||||
throw new RepositoryError('Error al recuperar datos del dashboard', { cause: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/index.ts
31
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
18
src/infrastructure/monitor-routes-http.ts
Normal file
18
src/infrastructure/monitor-routes-http.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/views/add-project.ejs
Normal file
33
src/views/add-project.ejs
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Nuevo Proyecto</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #f9f9f9; }
|
||||
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
label { display: block; margin-bottom: 8px; font-weight: bold; }
|
||||
input { width: 100%; padding: 10px; margin-bottom: 20px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
|
||||
.actions { display: flex; justify-content: space-between; align-items: center; }
|
||||
button { background: #2e7d32; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
|
||||
a { color: #666; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Añadir Proyecto</h1>
|
||||
<form action="/proyectos" method="POST">
|
||||
<label>Nombre</label>
|
||||
<input type="text" name="nombre" required placeholder="Nombre del servicio">
|
||||
|
||||
<label>URL de Health Check</label>
|
||||
<input type="url" name="url_health" required placeholder="https://mi-api.com/health">
|
||||
|
||||
<div class="actions">
|
||||
<a href="/">Volver</a>
|
||||
<button type="submit">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,7 +11,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<%# Vista genérica para captura de rrores mediante el middleware de Express %>
|
||||
<%# Vista genérica para captura de errores mediante el middleware de Express %>
|
||||
<h1>Error</h1>
|
||||
<p><%= message %></p>
|
||||
<a href="/">Volver al inicio</a>
|
||||
|
||||
@@ -5,52 +5,189 @@
|
||||
<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; }
|
||||
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; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
gap: 16px;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.btn-add { background: #2e7d32; color: white; }
|
||||
.btn-refresh { background: #0277bd; color: white; }
|
||||
.btn-mini { padding: 4px 8px; font-size: 16px; }
|
||||
.btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
|
||||
.refresh-group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.status-message { color: #2e7d32; min-width: 70px; }
|
||||
input { padding: 6px; border-radius: 4px; border: 1px solid #ddd; }
|
||||
input[type="number"] { text-align: right; }
|
||||
</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>
|
||||
<h1>Monitor de Salud</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<a href="/nuevo" class="btn btn-add">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 8v8"></path>
|
||||
<path d="M8 12h8"></path>
|
||||
</svg>
|
||||
Añadir Proyecto
|
||||
</a>
|
||||
<button class="btn btn-refresh" onclick="runCheck('/check-all', this)">Actualizar Todo</button>
|
||||
</div>
|
||||
|
||||
<div class="refresh-group">
|
||||
<label for="refreshSeconds">Comprobar estados cada:</label>
|
||||
<input type="number" id="refreshSeconds" min="10" max="3600" value="<%= monitorIntervalSeconds %>" style="width: 60px;">
|
||||
<span>segundos</span>
|
||||
<button class="btn btn-mini" onclick="updateMonitorInterval(this)">Aplicar</button>
|
||||
<span id="intervalStatus" class="status-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Proyecto</th>
|
||||
<th>URL</th>
|
||||
<th>Estado</th>
|
||||
<th>Ultima comprobacion</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% 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>
|
||||
<td class="<%= p.estado_codigo || 'PENDIENTE' %>">
|
||||
<%= p.estado_codigo || 'PENDIENTE' %>
|
||||
</td>
|
||||
<td>
|
||||
<%= p.fecha ? new Date(p.fecha).toLocaleString('es-ES') : '-' %>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-refresh btn-mini" onclick="runCheck('/check/<%= p.id %>', this)">Refrescar</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
const monitorIntervalSeconds = <%= monitorIntervalSeconds %>;
|
||||
|
||||
function schedulePageRefresh(seconds) {
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
async function runCheck(url, btn) {
|
||||
const originalText = btn.innerText;
|
||||
btn.disabled = true;
|
||||
btn.innerText = "...";
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert("Error al actualizar");
|
||||
btn.disabled = false;
|
||||
btn.innerText = originalText;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fallo en la peticion:", error);
|
||||
btn.disabled = false;
|
||||
btn.innerText = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMonitorInterval(btn) {
|
||||
const seconds = parseInt(document.getElementById('refreshSeconds').value, 10);
|
||||
const status = document.getElementById('intervalStatus');
|
||||
const originalText = btn.innerText;
|
||||
|
||||
if (!Number.isInteger(seconds) || seconds < 10 || seconds > 3600) {
|
||||
alert("El intervalo debe estar entre 10 y 3600 segundos.");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerText = "...";
|
||||
status.innerText = "";
|
||||
|
||||
try {
|
||||
const response = await fetch('/monitor-interval', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seconds })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert("Error al guardar el intervalo");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fallo guardando el intervalo:", error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerText = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
schedulePageRefresh(monitorIntervalSeconds);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user