aboutsummaryrefslogtreecommitdiff
path: root/backend/internal/monitor
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
commit6cc0bfd1d79074df790272b8091b1f0226d14283 (patch)
treecab18b0f708cf3b0eaeeeb98e2cf81459365b33e /backend/internal/monitor
downloadyaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz
yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'backend/internal/monitor')
-rw-r--r--backend/internal/monitor/checker.go86
-rw-r--r--backend/internal/monitor/worker.go148
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)
+}