From 5f92934fba6e349a213c3e08f4be25fc36662cec Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:33:48 -0500 Subject: [PATCH] feat(url-monitor): agregar monitoreo de disponibilidad de URLs desde servidor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modelos UrlMonitor y UrlMonitorLog con purga automática a 7 días - Cron cada minuto ejecuta chequeos HEAD→GET con fallback, alerta Telegram en cambio de estado - CRUD completo + chequeo manual + historial de logs - Migración automática de tablas Co-Authored-By: Claude Sonnet 4.6 --- migrations/migrate.go | 3 + pkg/models/url_monitor.go | 102 +++++++++++++++++++ pkg/services/cron_service.go | 111 +++++++++++++++++++++ rest/controllers/url_monitor_controller.go | 105 +++++++++++++++++++ rest/routes/user.go | 9 ++ 5 files changed, 330 insertions(+) create mode 100644 pkg/models/url_monitor.go create mode 100644 rest/controllers/url_monitor_controller.go diff --git a/migrations/migrate.go b/migrations/migrate.go index c1384d8..0d05ef7 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -98,6 +98,9 @@ func Migrate() { &models.CoolifyConfig{}, // Historial de métricas del agente &models.ServidorMetricasHistory{}, + // Monitor de disponibilidad de URLs + &models.UrlMonitor{}, + &models.UrlMonitorLog{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } diff --git a/pkg/models/url_monitor.go b/pkg/models/url_monitor.go new file mode 100644 index 0000000..2e9a8c5 --- /dev/null +++ b/pkg/models/url_monitor.go @@ -0,0 +1,102 @@ +package models + +import ( + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +type UrlMonitor struct { + gorm.Model + Nombre string `json:"nombre" gorm:"column:nombre;size:100"` + URL string `json:"url" gorm:"column:url;size:500"` + IntervaloMin int `json:"intervalo_min" gorm:"column:intervalo_min;default:5"` + TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` + // Estado actual + UltimoStatus int `json:"ultimo_status" gorm:"column:ultimo_status"` + UltimaLatMs int64 `json:"ultima_lat_ms" gorm:"column:ultima_lat_ms"` + UltimoCheckAt *time.Time `json:"ultimo_check_at" gorm:"column:ultimo_check_at"` + UltimoOk *bool `json:"ultimo_ok" gorm:"column:ultimo_ok"` +} + +func (UrlMonitor) TableName() string { return "url_monitor" } + +type UrlMonitorLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + MonitorID uint `json:"monitor_id" gorm:"column:monitor_id;index"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"` + StatusCode int `json:"status_code" gorm:"column:status_code"` + LatenciaMs int64 `json:"latencia_ms" gorm:"column:latencia_ms"` + Ok bool `json:"ok" gorm:"column:ok"` + Error string `json:"error" gorm:"column:error;type:text"` +} + +func (UrlMonitorLog) TableName() string { return "url_monitor_log" } + +func GetAllUrlMonitors() ([]UrlMonitor, error) { + var items []UrlMonitor + err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error + return items, err +} + +func GetActiveUrlMonitors() ([]UrlMonitor, error) { + var items []UrlMonitor + err := app.Http.Database.DB.Where("activo = true").Find(&items).Error + return items, err +} + +func GetUrlMonitorByID(id uint) (*UrlMonitor, error) { + var m UrlMonitor + err := app.Http.Database.DB.First(&m, id).Error + return &m, err +} + +func CreateUrlMonitor(m *UrlMonitor) error { + return app.Http.Database.DB.Create(m).Error +} + +func SaveUrlMonitor(m *UrlMonitor) error { + return app.Http.Database.DB.Save(m).Error +} + +func DeleteUrlMonitor(id uint) error { + return app.Http.Database.DB.Delete(&UrlMonitor{}, id).Error +} + +func UpdateUrlMonitorStatus(id uint, statusCode int, latMs int64, ok bool) error { + now := time.Now() + return app.Http.Database.DB.Model(&UrlMonitor{}).Where("id = ?", id).Updates(map[string]interface{}{ + "ultimo_status": statusCode, + "ultima_lat_ms": latMs, + "ultimo_check_at": &now, + "ultimo_ok": ok, + }).Error +} + +func InsertUrlMonitorLog(monitorID uint, statusCode int, latMs int64, ok bool, errStr string) { + app.Http.Database.DB.Create(&UrlMonitorLog{ + MonitorID: monitorID, + CreatedAt: time.Now(), + StatusCode: statusCode, + LatenciaMs: latMs, + Ok: ok, + Error: errStr, + }) +} + +func GetUrlMonitorLogs(monitorID uint, horas int) ([]UrlMonitorLog, error) { + var items []UrlMonitorLog + desde := time.Now().Add(-time.Duration(horas) * time.Hour) + err := app.Http.Database.DB. + Where("monitor_id = ? AND created_at >= ?", monitorID, desde). + Order("created_at ASC"). + Find(&items).Error + return items, err +} + +func PurgarUrlMonitorLogs(dias int) error { + corte := time.Now().AddDate(0, 0, -dias) + return app.Http.Database.DB.Where("created_at < ?", corte).Delete(&UrlMonitorLog{}).Error +} diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index 4f64cbb..c5af18a 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "net/http" "sort" "strconv" "time" @@ -55,11 +56,20 @@ func IniciarCron() { } else { log.Println("[CRON] Historial de métricas purgado (>7 días)") } + if err := models.PurgarUrlMonitorLogs(7); err != nil { + log.Printf("[CRON] Error purgando logs URL monitor: %v", err) + } }); err != nil { log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err) return } + // Monitor de URLs — cada minuto + if _, err := cronScheduler.AddFunc("* * * * *", VerificarUrlMonitors); err != nil { + log.Printf("[CRON] Error registrando tarea url_monitor: %v", err) + return + } + cronScheduler.Start() log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min") } @@ -362,3 +372,104 @@ func crearNotifServidor(titulo, cuerpo, evento string) { sendTelegramAdmin(msg) } } + +// ─── Monitor de URLs ────────────────────────────────────────────────────────── + +func VerificarUrlMonitors() { + monitors, err := models.GetActiveUrlMonitors() + if err != nil || len(monitors) == 0 { + return + } + now := time.Now() + for _, m := range monitors { + m := m + if m.UltimoCheckAt != nil { + if now.Sub(*m.UltimoCheckAt).Minutes() < float64(m.IntervaloMin) { + continue + } + } + go ejecutarChequeoURL(m) + } +} + +func EjecutarChequeoURLPublic(m models.UrlMonitor) { ejecutarChequeoURL(m) } + +func ejecutarChequeoURL(m models.UrlMonitor) { + statusCode, latMs, err := doHttpCheck(m.URL, m.TimeoutSeg) + ok := err == nil && statusCode >= 200 && statusCode < 400 + errStr := "" + if err != nil { + errStr = err.Error() + } + + anteriorOk := m.UltimoOk == nil || *m.UltimoOk + + models.UpdateUrlMonitorStatus(m.ID, statusCode, latMs, ok) + models.InsertUrlMonitorLog(m.ID, statusCode, latMs, ok, errStr) + + if m.UltimoOk == nil { + return // primer chequeo, no alertar + } + if !ok && anteriorOk { + detalle := errStr + if detalle == "" { + detalle = fmt.Sprintf("HTTP %d", statusCode) + } + msg := fmt.Sprintf("🔴 Sitio caído\n%s\n%s\n\nError: %s", + escapeTelegramHTML(m.Nombre), + escapeTelegramHTML(m.URL), + escapeTelegramHTML(detalle)) + sendTelegramAdmin(msg) + log.Printf("[URL-MON] DOWN %s — %s", m.Nombre, detalle) + } else if ok && !anteriorOk { + msg := fmt.Sprintf("✅ Sitio recuperado\n%s\n%s\nLatencia: %dms", + escapeTelegramHTML(m.Nombre), + escapeTelegramHTML(m.URL), + latMs) + sendTelegramAdmin(msg) + log.Printf("[URL-MON] UP %s — %dms", m.Nombre, latMs) + } +} + +func doHttpCheck(url string, timeoutSeg int) (int, int64, error) { + if timeoutSeg <= 0 { + timeoutSeg = 10 + } + client := &http.Client{ + Timeout: time.Duration(timeoutSeg) * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("demasiadas redirecciones") + } + return nil + }, + } + + start := time.Now() + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return 0, 0, err + } + req.Header.Set("User-Agent", "usite-monitor/1.0") + + resp, err := client.Do(req) + latMs := time.Since(start).Milliseconds() + if err != nil || (resp != nil && resp.StatusCode == http.StatusMethodNotAllowed) { + if resp != nil { + resp.Body.Close() + } + // Reintentar con GET + start = time.Now() + req2, _ := http.NewRequest(http.MethodGet, url, nil) + req2.Header.Set("User-Agent", "usite-monitor/1.0") + resp2, err2 := client.Do(req2) + latMs = time.Since(start).Milliseconds() + if err2 != nil { + return 0, latMs, err2 + } + resp2.Body.Close() + return resp2.StatusCode, latMs, nil + } + resp.Body.Close() + return resp.StatusCode, latMs, nil +} diff --git a/rest/controllers/url_monitor_controller.go b/rest/controllers/url_monitor_controller.go new file mode 100644 index 0000000..e0241fa --- /dev/null +++ b/rest/controllers/url_monitor_controller.go @@ -0,0 +1,105 @@ +package controllers + +import ( + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +func UrlMonitorIndex(c *fiber.Ctx) error { + return c.Render("url_monitor", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetUrlMonitors(c *fiber.Ctx) error { + items, err := models.GetAllUrlMonitors() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func CreateUrlMonitor(c *fiber.Ctx) error { + var m models.UrlMonitor + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if strings.TrimSpace(m.Nombre) == "" || strings.TrimSpace(m.URL) == "" { + return c.Status(400).JSON(fiber.Map{"error": "nombre y url son requeridos"}) + } + if m.IntervaloMin <= 0 { + m.IntervaloMin = 5 + } + if m.TimeoutSeg <= 0 { + m.TimeoutSeg = 10 + } + m.Activo = true + if err := models.CreateUrlMonitor(&m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(m) +} + +func UpdateUrlMonitor(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + existing, err := models.GetUrlMonitorByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "no encontrado"}) + } + if err := c.BodyParser(existing); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + existing.ID = uint(id) + if err := models.SaveUrlMonitor(existing); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteUrlMonitorHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteUrlMonitor(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func CheckUrlMonitorNow(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + m, err := models.GetUrlMonitorByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "no encontrado"}) + } + go services.EjecutarChequeoURLPublic(*m) + return c.JSON(fiber.Map{"ok": true, "message": "Chequeo iniciado"}) +} + +func GetUrlMonitorLogsHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + horas := c.QueryInt("horas", 24) + if horas <= 0 || horas > 168 { + horas = 24 + } + items, err := models.GetUrlMonitorLogs(uint(id), horas) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index f655e4a..a9a6745 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -84,6 +84,15 @@ func UserRoutes(app fiber.Router) { protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory) protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger) + // Monitor de URLs + protected.Get("/url-monitor", middlewares.MenuMiddleware, controllers.UrlMonitorIndex) + protected.Get("/url-monitors", controllers.GetUrlMonitors) + protected.Post("/url-monitor", controllers.CreateUrlMonitor) + protected.Put("/url-monitor/:id", controllers.UpdateUrlMonitor) + protected.Delete("/url-monitor/:id", controllers.DeleteUrlMonitorHandler) + protected.Post("/url-monitor/:id/check", controllers.CheckUrlMonitorNow) + protected.Get("/url-monitor/:id/logs", controllers.GetUrlMonitorLogsHandler) + // Rutas de proveedores de servidor protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor) protected.Get("/loadprovservidor", controllers.GetProvServidor)