diff options
Diffstat (limited to 'backend/internal/monitor')
| -rw-r--r-- | backend/internal/monitor/checker.go | 86 | ||||
| -rw-r--r-- | backend/internal/monitor/worker.go | 148 |
2 files changed, 234 insertions, 0 deletions
diff --git a/backend/internal/monitor/checker.go b/backend/internal/monitor/checker.go new file mode 100644 index 0000000..07f807e --- /dev/null +++ b/backend/internal/monitor/checker.go @@ -0,0 +1,86 @@ +package monitor + +import ( + "context" + "io" + "net/http" + "strings" + "time" + + "yaum/internal/model" +) + +var httpClient = &http.Client{ + Timeout: 10 * time.Second, +} + +func CheckHTTP(ctx context.Context, url string, keyword string) model.Heartbeat { + start := time.Now() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return heartbeatFromError(start, err) + } + + resp, err := httpClient.Do(req) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + return model.Heartbeat{ + StatusCode: 0, + ResponseTimeMs: elapsed, + IsUp: false, + ErrorMessage: err.Error(), + TestedAt: time.Now(), + } + } + defer resp.Body.Close() + + isUp := resp.StatusCode >= 200 && resp.StatusCode < 400 + errMsg := "" + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + isUp = false + errMsg = "Erro ao ler corpo: " + readErr.Error() + } else { + bodyStr := string(body) + + if keyword != "" && isUp { + if !strings.Contains(bodyStr, keyword) { + isUp = false + errMsg = "Palavra-chave nao encontrada" + } + } + + if !isUp { + snippet := strings.TrimSpace(bodyStr) + if len(snippet) > 300 { + snippet = snippet[:300] + } + if errMsg == "" { + errMsg = snippet + } else { + errMsg += " | " + snippet + } + } + } + + return model.Heartbeat{ + StatusCode: resp.StatusCode, + ResponseTimeMs: elapsed, + IsUp: isUp, + ErrorMessage: errMsg, + TestedAt: time.Now(), + } +} + +func heartbeatFromError(start time.Time, err error) model.Heartbeat { + return model.Heartbeat{ + StatusCode: 0, + ResponseTimeMs: time.Since(start).Milliseconds(), + IsUp: false, + ErrorMessage: err.Error(), + TestedAt: time.Now(), + } +} diff --git a/backend/internal/monitor/worker.go b/backend/internal/monitor/worker.go new file mode 100644 index 0000000..0cf980a --- /dev/null +++ b/backend/internal/monitor/worker.go @@ -0,0 +1,148 @@ +package monitor + +import ( + "context" + "encoding/json" + "log" + "time" + + "yaum/internal/alerts" + "yaum/internal/model" + "yaum/internal/sse" + "yaum/internal/store" +) + +type checkResult struct { + Heartbeat model.Heartbeat + Service model.Service +} + +type Worker struct { + store store.Store + interval time.Duration + timeout time.Duration + results chan checkResult + broadcaster *sse.Broadcaster + smtpCfg *alerts.SMTPConfig + quit chan struct{} +} + +func NewWorker(s store.Store, interval, timeout time.Duration, b *sse.Broadcaster) *Worker { + return &Worker{ + store: s, + interval: interval, + timeout: timeout, + results: make(chan checkResult, 100), + broadcaster: b, + quit: make(chan struct{}), + } +} + +func (w *Worker) WithSMTP(cfg *alerts.SMTPConfig) *Worker { + w.smtpCfg = cfg + return w +} + +func (w *Worker) Start(ctx context.Context) { + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + log.Printf("[worker] iniciado | intervalo=%v timeout=%v", w.interval, w.timeout) + + go w.resultConsumer() + + w.runChecks(ctx) + + for { + select { + case <-ticker.C: + w.runChecks(ctx) + case <-w.quit: + log.Println("[worker] encerrando...") + return + } + } +} + +func (w *Worker) resultConsumer() { + for { + select { + case res := <-w.results: + hb := res.Heartbeat + svc := res.Service + + w.checkTransition(svc, hb) + + if err := w.store.AddHeartbeat(hb); err != nil { + log.Printf("[worker] erro ao salvar heartbeat: %v", err) + } + + if w.broadcaster != nil { + data, err := json.Marshal(hb) + if err == nil { + w.broadcaster.Publish(data) + } + } + + status := "UP" + if !hb.IsUp { + status = "DOWN" + } + log.Printf("[worker] service=%d status=%s code=%d time=%dms", + hb.ServiceID, status, hb.StatusCode, hb.ResponseTimeMs) + case <-w.quit: + return + } + } +} + +func (w *Worker) checkTransition(svc model.Service, hb model.Heartbeat) { + prev, err := w.store.GetLastHeartbeat(svc.ID) + if err != nil || prev == nil { + return + } + + if prev.IsUp == hb.IsUp { + return + } + + log.Printf("[worker] transicao de status service=%d: %v -> %v", svc.ID, prev.IsUp, hb.IsUp) + + w.fireAlerts(svc, hb, prev.IsUp) +} + +func (w *Worker) fireAlerts(svc model.Service, hb model.Heartbeat, wasUp bool) { + if svc.DiscordWebhookURL != "" { + go alerts.SendDiscord(svc.DiscordWebhookURL, svc, hb, wasUp) + } + if svc.AlertEmail != "" && w.smtpCfg != nil { + go alerts.SendEmail(svc, hb, wasUp, w.smtpCfg) + } +} + +func (w *Worker) runChecks(ctx context.Context) { + services, err := w.store.ListActiveServices() + if err != nil { + log.Printf("[worker] erro ao listar servicos ativos: %v", err) + return + } + log.Printf("[worker] verificando %d servico(s)", len(services)) + + for _, svc := range services { + go w.checkService(ctx, svc) + } +} + +func (w *Worker) checkService(ctx context.Context, svc model.Service) { + checkCtx, cancel := context.WithTimeout(ctx, w.timeout) + defer cancel() + + hb := CheckHTTP(checkCtx, svc.URL, svc.KeywordToFind) + hb.ServiceID = svc.ID + + w.results <- checkResult{Heartbeat: hb, Service: svc} +} + +func (w *Worker) Stop() { + close(w.quit) +} |