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:
Lizandro Guarnizo
2026-07-06 09:33:48 -05:00
co-authored by Claude Sonnet 4.6
parent 569d8b0f28
commit 5f92934fba
5 changed files with 330 additions and 0 deletions
+102
View File
@@ -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
}
+111
View File
@@ -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("🔴 <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
}