feat(url-monitor): agregar monitoreo de disponibilidad de URLs desde servidor
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
569d8b0f28
commit
5f92934fba
@@ -98,6 +98,9 @@ func Migrate() {
|
|||||||
&models.CoolifyConfig{},
|
&models.CoolifyConfig{},
|
||||||
// Historial de métricas del agente
|
// Historial de métricas del agente
|
||||||
&models.ServidorMetricasHistory{},
|
&models.ServidorMetricasHistory{},
|
||||||
|
// Monitor de disponibilidad de URLs
|
||||||
|
&models.UrlMonitor{},
|
||||||
|
&models.UrlMonitorLog{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("Error during main migration: %v", err)
|
log.Fatalf("Error during main migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -55,11 +56,20 @@ func IniciarCron() {
|
|||||||
} else {
|
} else {
|
||||||
log.Println("[CRON] Historial de métricas purgado (>7 días)")
|
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 {
|
}); err != nil {
|
||||||
log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err)
|
log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err)
|
||||||
return
|
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()
|
cronScheduler.Start()
|
||||||
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min")
|
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)
|
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("🔴 <b>Sitio caído</b>\n<b>%s</b>\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("✅ <b>Sitio recuperado</b>\n<b>%s</b>\n%s\nLatencia: <b>%dms</b>",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -84,6 +84,15 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory)
|
protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory)
|
||||||
protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger)
|
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
|
// Rutas de proveedores de servidor
|
||||||
protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor)
|
protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor)
|
||||||
protected.Get("/loadprovservidor", controllers.GetProvServidor)
|
protected.Get("/loadprovservidor", controllers.GetProvServidor)
|
||||||
|
|||||||
Reference in New Issue
Block a user