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:
2026-05-06 07:04:48 +00:00
17 changed files with 543 additions and 156 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# TypeScript build output
dist/
# ---> Node
# Logs
logs
@@ -142,3 +145,4 @@ dist
# JetBrains IDE local state
.idea/

View File

@@ -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.

View File

@@ -9,6 +9,7 @@
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {

View 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);
}
}
}

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

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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) });
}
}
}

View File

@@ -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}`);
});
});

View 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;
}

View File

@@ -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;
}

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)
});
}
}
}

33
src/views/add-project.ejs Normal file
View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}
}