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)