aboutsummaryrefslogtreecommitdiff
path: root/backend/internal
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
downloadyaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz
yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'backend/internal')
-rw-r--r--backend/internal/alerts/alerts.go113
-rw-r--r--backend/internal/alerts/smtp.go85
-rw-r--r--backend/internal/api/auth.go144
-rw-r--r--backend/internal/api/handler.go243
-rw-r--r--backend/internal/api/router.go71
-rw-r--r--backend/internal/api/sse.go57
-rw-r--r--backend/internal/config/config.go125
-rw-r--r--backend/internal/model/types.go52
-rw-r--r--backend/internal/monitor/checker.go86
-rw-r--r--backend/internal/monitor/worker.go148
-rw-r--r--backend/internal/sse/broadcaster.go42
-rw-r--r--backend/internal/store/memory.go277
-rw-r--r--backend/internal/store/postgres.go324
-rw-r--r--backend/internal/store/store.go23
14 files changed, 1790 insertions, 0 deletions
diff --git a/backend/internal/alerts/alerts.go b/backend/internal/alerts/alerts.go
new file mode 100644
index 0000000..44d4a50
--- /dev/null
+++ b/backend/internal/alerts/alerts.go
@@ -0,0 +1,113 @@
+package alerts
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "yaum/internal/model"
+)
+
+type SMTPConfig struct {
+ Host string
+ Port int
+ Username string
+ Password string
+ From string
+}
+
+func SendDiscord(webhookURL string, svc model.Service, hb model.Heartbeat, wasUp bool) {
+ color := 15548997
+ title := "🔴 Site Fora do Ar"
+ if hb.IsUp {
+ color = 5763719
+ title = "🟢 Site Recuperado"
+ }
+
+ desc := "o servico caiu"
+ fields := []map[string]any{
+ {"name": "URL", "value": svc.URL, "inline": true},
+ {"name": "Codigo", "value": fmt.Sprintf("`%d`", hb.StatusCode), "inline": true},
+ {"name": "Tempo", "value": fmt.Sprintf("%dms", hb.ResponseTimeMs), "inline": true},
+ }
+ if hb.ErrorMessage != "" {
+ fields = append(fields, map[string]any{"name": "Erro", "value": hb.ErrorMessage, "inline": false})
+ }
+ if hb.IsUp {
+ desc = "o servico voltou"
+ }
+
+ payload := map[string]any{
+ "embeds": []map[string]any{
+ {
+ "title": title + " | " + svc.Name,
+ "description": desc,
+ "color": color,
+ "fields": fields,
+ "footer": map[string]any{"text": "YAUM — Yet Another Uptime Monitor"},
+ "timestamp": hb.TestedAt.Format(time.RFC3339),
+ },
+ },
+ "username": "YAUM",
+ }
+
+ data, _ := json.Marshal(payload)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Post(webhookURL, "application/json", bytes.NewReader(data))
+ if err != nil {
+ log.Printf("[alerta:discord] erro ao enviar: %v", err)
+ return
+ }
+ resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ log.Printf("[alerta:discord] webhook retornou %d", resp.StatusCode)
+ return
+ }
+ log.Printf("[alerta:discord] webhook enviado para '%s'", svc.Name)
+}
+
+func SendEmail(svc model.Service, hb model.Heartbeat, wasUp bool, cfg *SMTPConfig) {
+ if cfg == nil || cfg.From == "" || cfg.Host == "" {
+ log.Println("[alerta:email] smtp nao configurado — pulando")
+ return
+ }
+
+ statusEmoji := "🟢"
+ statusTxt := "UP"
+ subject := "[YAUM] Site Recuperado: " + svc.Name
+ if !hb.IsUp {
+ statusEmoji = "🔴"
+ statusTxt = "DOWN"
+ subject = "[YAUM] Site Fora do Ar: " + svc.Name
+ }
+
+ body := fmt.Sprintf(`YAUM — Yet Another Uptime Monitor
+
+%s Status: %s
+
+Servico: %s
+URL: %s
+Status HTTP: %d
+Tempo de Resposta: %dms
+Mensagem: %s
+Testado em: %s
+`,
+ statusEmoji, statusTxt,
+ svc.Name, svc.URL, hb.StatusCode, hb.ResponseTimeMs, hb.ErrorMessage,
+ hb.TestedAt.Format("2006-01-02 15:04:05 MST"),
+ )
+
+ if err := sendMail(hostPort(cfg.Host, cfg.Port), cfg.Username, cfg.Password, cfg.From, []string{svc.AlertEmail}, subject, body); err != nil {
+ log.Printf("[alerta:email] erro: %v", err)
+ return
+ }
+ log.Printf("[alerta:email] email enviado para %s", svc.AlertEmail)
+}
+
+func hostPort(host string, port int) string {
+ return fmt.Sprintf("%s:%d", host, port)
+}
diff --git a/backend/internal/alerts/smtp.go b/backend/internal/alerts/smtp.go
new file mode 100644
index 0000000..5bb1ba8
--- /dev/null
+++ b/backend/internal/alerts/smtp.go
@@ -0,0 +1,85 @@
+package alerts
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net/smtp"
+ "strings"
+)
+
+func sendMail(addr, user, pass, from string, to []string, subject, body string) error {
+ host := addr
+ for i, c := range addr {
+ if c == ':' {
+ host = addr[:i]
+ break
+ }
+ }
+
+ msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
+ from, strings.Join(to, ", "), subject, body)
+
+ auth := newLoginAuth(user, pass)
+
+ c, err := smtp.Dial(addr)
+ if err != nil {
+ return fmt.Errorf("dial: %w", err)
+ }
+ defer c.Close()
+
+ if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
+ return fmt.Errorf("starttls: %w", err)
+ }
+
+ if user != "" {
+ if err := c.Auth(auth); err != nil {
+ return fmt.Errorf("auth: %w", err)
+ }
+ }
+
+ if err := c.Mail(from); err != nil {
+ return fmt.Errorf("mail from: %w", err)
+ }
+ for _, addr := range to {
+ if err := c.Rcpt(addr); err != nil {
+ return fmt.Errorf("rcpt %s: %w", addr, err)
+ }
+ }
+
+ w, err := c.Data()
+ if err != nil {
+ return fmt.Errorf("data: %w", err)
+ }
+ if _, err := fmt.Fprint(w, msg); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+ return c.Quit()
+}
+
+type loginAuth struct {
+ user, pass string
+}
+
+func newLoginAuth(user, pass string) smtp.Auth {
+ return &loginAuth{user, pass}
+}
+
+func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
+ return "LOGIN", []byte(a.user), nil
+}
+
+func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+ if more {
+ msg := string(fromServer)
+ switch {
+ case strings.EqualFold(msg, "Username:"):
+ return []byte(a.user), nil
+ case strings.EqualFold(msg, "Password:"):
+ return []byte(a.pass), nil
+ }
+ }
+ return nil, nil
+}
diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go
new file mode 100644
index 0000000..5095194
--- /dev/null
+++ b/backend/internal/api/auth.go
@@ -0,0 +1,144 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type AuthHandler struct {
+ username string
+ passwordHash string
+ jwtSecret []byte
+}
+
+type loginRequest struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+type Claims struct {
+ Username string `json:"username"`
+ jwt.RegisteredClaims
+}
+
+func NewAuthHandler(username, passwordHash, jwtSecret string) *AuthHandler {
+ if username == "" {
+ username = "admin"
+ }
+ secret := []byte(jwtSecret)
+ if len(secret) == 0 {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ secret = []byte("dev-secret-do-not-use-in-production")
+ } else {
+ secret = []byte(base64.RawURLEncoding.EncodeToString(key))
+ }
+ log.Println("[auth] jwt_secret nao configurado — usando chave gerada aleatoriamente")
+ }
+ if passwordHash == "" {
+ log.Fatal("[auth] ADMIN_PASSWORD_HASH ou ADMIN_PASSWORD deve ser definido")
+ }
+ return &AuthHandler{
+ username: username,
+ passwordHash: passwordHash,
+ jwtSecret: secret,
+ }
+}
+
+func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+ var req loginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido"})
+ return
+ }
+ if req.Username != a.username {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "credenciais invalidas"})
+ return
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(a.passwordHash), []byte(req.Password)); err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "credenciais invalidas"})
+ return
+ }
+
+ now := time.Now()
+ claims := Claims{
+ Username: req.Username,
+ RegisteredClaims: jwt.RegisteredClaims{
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)),
+ Subject: req.Username,
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signed, err := token.SignedString(a.jwtSecret)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "erro ao gerar token"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"token": signed})
+}
+
+func (a *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) {
+ tokenStr := extractToken(r)
+ if tokenStr == "" {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token ausente"})
+ return
+ }
+ if !a.validateToken(tokenStr) {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token invalido ou expirado"})
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (a *AuthHandler) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/auth/") {
+ next.ServeHTTP(w, r)
+ return
+ }
+ if r.Method == http.MethodGet || r.Method == http.MethodOptions {
+ next.ServeHTTP(w, r)
+ return
+ }
+ tokenStr := extractToken(r)
+ if tokenStr == "" || !a.validateToken(tokenStr) {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "nao autorizado"})
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (a *AuthHandler) validateToken(tokenStr string) bool {
+ claims := &Claims{}
+ token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
+ return a.jwtSecret, nil
+ })
+ if err != nil || !token.Valid {
+ return false
+ }
+ return claims.Username == a.username
+}
+
+func extractToken(r *http.Request) string {
+ auth := r.Header.Get("Authorization")
+ if strings.HasPrefix(auth, "Bearer ") {
+ return strings.TrimPrefix(auth, "Bearer ")
+ }
+ cookie, err := r.Cookie("auth_token")
+ if err == nil && cookie.Value != "" {
+ return cookie.Value
+ }
+ return ""
+}
diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go
new file mode 100644
index 0000000..f304351
--- /dev/null
+++ b/backend/internal/api/handler.go
@@ -0,0 +1,243 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "yaum/internal/monitor"
+ "yaum/internal/model"
+ "yaum/internal/store"
+)
+
+type ServiceResponse struct {
+ model.Service
+ LastHeartbeat *model.Heartbeat `json:"last_heartbeat,omitempty"`
+}
+
+type Handler struct {
+ store store.Store
+ timeout int
+}
+
+func NewHandler(s store.Store, timeoutSec int) *Handler {
+ return &Handler{store: s, timeout: timeoutSec}
+}
+
+func (h *Handler) ListServices(w http.ResponseWriter, r *http.Request) {
+ services, err := h.store.ListServices()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ resp := make([]ServiceResponse, len(services))
+ for i, svc := range services {
+ resp[i] = ServiceResponse{
+ Service: svc.Service,
+ LastHeartbeat: svc.LastHeartbeat,
+ }
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
+ var svc model.Service
+ if err := json.NewDecoder(r.Body).Decode(&svc); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()})
+ return
+ }
+ if svc.Name == "" || svc.URL == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name e url sao obrigatorios"})
+ return
+ }
+ created, err := h.store.AddService(svc)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, created)
+}
+
+func (h *Handler) ToggleService(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ svc, err := h.store.ToggleService(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, svc)
+}
+
+func (h *Handler) TestService(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+
+ svc, err := h.store.GetService(id)
+ if err != nil || svc == nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "servico nao encontrado"})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.timeout)*time.Second)
+ defer cancel()
+
+ hb := monitor.CheckHTTP(ctx, svc.URL, svc.KeywordToFind)
+ hb.ServiceID = svc.ID
+
+ if saverr := h.store.AddHeartbeat(hb); saverr != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": saverr.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, hb)
+}
+
+func (h *Handler) UpdateService(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+
+ var svc model.Service
+ if err := json.NewDecoder(r.Body).Decode(&svc); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()})
+ return
+ }
+
+ updated, err := h.store.UpdateService(id, svc)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, updated)
+}
+
+func (h *Handler) DeleteService(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ if err := h.store.DeleteService(id); err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ history, err := h.store.GetHistory(id, 50)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, history)
+}
+
+func (h *Handler) GetActiveMaintenance(w http.ResponseWriter, r *http.Request) {
+ m, err := h.store.GetActiveMaintenance(time.Now())
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if m == nil {
+ writeJSON(w, http.StatusOK, map[string]any{"maintenance": nil})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"maintenance": m})
+}
+
+func (h *Handler) BadgeSVG(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(badgeSVG("ERRO", "#888")))
+ return
+ }
+
+ svc, err := h.store.GetService(id)
+ if err != nil || svc == nil {
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte(badgeSVG("N/A", "#888")))
+ return
+ }
+
+ stats, _ := h.store.GetServiceStats(id)
+ lastHb, _ := h.store.GetLastHeartbeat(id)
+
+ var label, color string
+ if lastHb == nil {
+ label = "AGUARDANDO"
+ color = "#888"
+ } else if lastHb.IsUp {
+ if stats != nil && stats.Uptime30d > 0 {
+ label = "UP " + fmt.Sprintf("%.1f", stats.Uptime30d) + "%"
+ } else {
+ label = "UP"
+ }
+ color = "#22f06a"
+ } else {
+ label = "DOWN"
+ color = "#ff4060"
+ }
+
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Write([]byte(badgeSVG(label, color)))
+}
+
+func badgeSVG(label string, color string) string {
+ textWidth := len(label) * 8
+ if textWidth < 40 {
+ textWidth = 40
+ }
+ totalWidth := 20 + textWidth + 8
+
+ return fmt.Sprintf(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
+ <rect width="%d" height="20" rx="3" fill="#333"/>
+ <rect x="%d" width="%d" height="20" rx="3" fill="%s"/>
+ <text x="%d" y="14" font-family="monospace, sans-serif" font-size="11" font-weight="bold" fill="#fff" text-anchor="middle">%s</text>
+</svg>`,
+ totalWidth, totalWidth, totalWidth-textWidth-8, textWidth+8, color,
+ 10+(textWidth/2), label,
+ )
+}
+
+func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ stats, err := h.store.GetServiceStats(id)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, stats)
+}
+
+func writeJSON(w http.ResponseWriter, status int, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
new file mode 100644
index 0000000..9a18cc5
--- /dev/null
+++ b/backend/internal/api/router.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "net/http"
+ "strings"
+)
+
+func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handler {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("POST /api/auth/login", auth.Login)
+ mux.HandleFunc("GET /api/auth/verify", auth.Verify)
+
+ mux.HandleFunc("GET /api/services", h.ListServices)
+ mux.HandleFunc("POST /api/services", h.CreateService)
+ mux.HandleFunc("PUT /api/services/{id}", h.UpdateService)
+ mux.HandleFunc("PATCH /api/services/{id}/toggle", h.ToggleService)
+ mux.HandleFunc("POST /api/services/{id}/test", h.TestService)
+ mux.HandleFunc("DELETE /api/services/{id}", h.DeleteService)
+ mux.HandleFunc("GET /api/services/{id}/history", h.GetHistory)
+ mux.HandleFunc("GET /api/services/{id}/stats", h.GetStats)
+ mux.HandleFunc("GET /api/services/{id}/badge.svg", h.BadgeSVG)
+
+ mux.HandleFunc("GET /api/active-maintenance", h.GetActiveMaintenance)
+
+ mux.Handle("GET /api/stream", stream)
+
+ return corsMiddleware(auth.Middleware(mux))
+}
+
+func corsMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+
+ if origin != "" && isAllowedOrigin(origin) {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Vary", "Origin")
+ } else {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ }
+
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ w.Header().Set("Access-Control-Max-Age", "86400")
+
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func isAllowedOrigin(origin string) bool {
+ allowed := []string{
+ "http://localhost:5173",
+ "http://localhost:4173",
+ "http://127.0.0.1:5173",
+ "http://127.0.0.1:4173",
+ }
+ for _, a := range allowed {
+ if strings.EqualFold(origin, a) {
+ return true
+ }
+ }
+ if strings.HasPrefix(origin, "http://localhost:") {
+ return true
+ }
+ return false
+}
diff --git a/backend/internal/api/sse.go b/backend/internal/api/sse.go
new file mode 100644
index 0000000..7c2332c
--- /dev/null
+++ b/backend/internal/api/sse.go
@@ -0,0 +1,57 @@
+package api
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+
+ "yaum/internal/sse"
+)
+
+type StreamHandler struct {
+ broadcaster *sse.Broadcaster
+}
+
+func NewStreamHandler(b *sse.Broadcaster) *StreamHandler {
+ return &StreamHandler{broadcaster: b}
+}
+
+func (sh *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ ch := sh.broadcaster.Subscribe()
+ defer sh.broadcaster.Unsubscribe(ch)
+
+ _, err := fmt.Fprintf(w, "event: connected\ndata: {}\n\n")
+ if err != nil {
+ return
+ }
+ flusher.Flush()
+
+ ctx := r.Context()
+ for {
+ select {
+ case data, ok := <-ch:
+ if !ok {
+ return
+ }
+ _, err := fmt.Fprintf(w, "event: heartbeat\ndata: %s\n\n", data)
+ if err != nil {
+ log.Printf("[sse] cliente desconectado: %v", err)
+ return
+ }
+ flusher.Flush()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
new file mode 100644
index 0000000..60d176e
--- /dev/null
+++ b/backend/internal/config/config.go
@@ -0,0 +1,125 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "time"
+)
+
+type Config struct {
+ Server ServerConfig `json:"server"`
+ Database DatabaseConfig `json:"database"`
+ Monitor MonitorConfig `json:"monitor"`
+ SMTP SMTPConfig `json:"smtp"`
+ Auth AuthConfig `json:"auth"`
+}
+
+type SMTPConfig struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ From string `json:"from"`
+}
+
+type AuthConfig struct {
+ AdminUsername string `json:"admin_username"`
+ AdminPasswordHash string `json:"admin_password_hash"`
+ JWTSecret string `json:"jwt_secret"`
+}
+
+type ServerConfig struct {
+ Addr string `json:"addr"`
+}
+
+type DatabaseConfig struct {
+ URL string `json:"url"`
+}
+
+type MonitorConfig struct {
+ Interval string `json:"interval"`
+ Timeout string `json:"timeout"`
+}
+
+func Load(path string) (*Config, error) {
+ if path == "" {
+ path = "config.json"
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("ler config %s: %w", path, err)
+ }
+
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("parse config %s: %w", path, err)
+ }
+
+ cfg.applyDefaults()
+ return &cfg, nil
+}
+
+func (c *Config) applyDefaults() {
+ if c.Server.Addr == "" {
+ c.Server.Addr = ":8080"
+ }
+ if c.Monitor.Interval == "" {
+ c.Monitor.Interval = "60s"
+ }
+ if c.Monitor.Timeout == "" {
+ c.Monitor.Timeout = "10s"
+ }
+}
+
+func (c *Config) ServerAddr() string {
+ if v := os.Getenv("ADDR"); v != "" {
+ return v
+ }
+ return c.Server.Addr
+}
+
+func (c *Config) DatabaseURL() string {
+ if v := os.Getenv("DATABASE_URL"); v != "" {
+ return v
+ }
+ return c.Database.URL
+}
+
+func (c *Config) CheckInterval() time.Duration {
+ d, err := time.ParseDuration(c.Monitor.Interval)
+ if err != nil {
+ return 60 * time.Second
+ }
+ return d
+}
+
+func (c *Config) CheckTimeout() time.Duration {
+ d, err := time.ParseDuration(c.Monitor.Timeout)
+ if err != nil {
+ return 10 * time.Second
+ }
+ return d
+}
+
+func (c *Config) AdminUsername() string {
+ if v := os.Getenv("ADMIN_USERNAME"); v != "" {
+ return v
+ }
+ return c.Auth.AdminUsername
+}
+
+func (c *Config) AdminPasswordHash() string {
+ if v := os.Getenv("ADMIN_PASSWORD_HASH"); v != "" {
+ return v
+ }
+ return c.Auth.AdminPasswordHash
+}
+
+func (c *Config) JWTSecret() string {
+ if v := os.Getenv("JWT_SECRET"); v != "" {
+ return v
+ }
+ return c.Auth.JWTSecret
+}
diff --git a/backend/internal/model/types.go b/backend/internal/model/types.go
new file mode 100644
index 0000000..4e439b8
--- /dev/null
+++ b/backend/internal/model/types.go
@@ -0,0 +1,52 @@
+package model
+
+import "time"
+
+type Service struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ GroupName string `json:"group_name"`
+ IntervalSeconds int `json:"interval_seconds"`
+ IsActive bool `json:"is_active"`
+ DiscordWebhookURL string `json:"discord_webhook_url,omitempty"`
+ AlertEmail string `json:"alert_email,omitempty"`
+ KeywordToFind string `json:"keyword_to_find,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type Heartbeat struct {
+ ID int `json:"id"`
+ ServiceID int `json:"service_id"`
+ StatusCode int `json:"status_code"`
+ ResponseTimeMs int64 `json:"response_time_ms"`
+ IsUp bool `json:"is_up"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ TestedAt time.Time `json:"tested_at"`
+}
+
+type Maintenance struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ StartTime time.Time `json:"start_time"`
+ EndTime time.Time `json:"end_time"`
+ IsActive bool `json:"is_active"`
+}
+
+type ServiceStats struct {
+ ServiceID int `json:"service_id"`
+ Uptime24h float64 `json:"uptime_24h"`
+ Uptime7d float64 `json:"uptime_7d"`
+ Uptime30d float64 `json:"uptime_30d"`
+ AvgResponseMs24h float64 `json:"avg_response_ms_24h"`
+ AvgResponseMs7d float64 `json:"avg_response_ms_7d"`
+ AvgResponseMs30d float64 `json:"avg_response_ms_30d"`
+ TotalChecks24h int `json:"total_checks_24h"`
+ TotalChecks7d int `json:"total_checks_7d"`
+ TotalChecks30d int `json:"total_checks_30d"`
+}
+
+type ServiceWithHeartbeat struct {
+ Service
+ LastHeartbeat *Heartbeat `json:"last_heartbeat,omitempty"`
+}
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)
+}
diff --git a/backend/internal/sse/broadcaster.go b/backend/internal/sse/broadcaster.go
new file mode 100644
index 0000000..02dbc65
--- /dev/null
+++ b/backend/internal/sse/broadcaster.go
@@ -0,0 +1,42 @@
+package sse
+
+import (
+ "sync"
+)
+
+type Broadcaster struct {
+ mu sync.RWMutex
+ subscribers map[chan []byte]struct{}
+}
+
+func NewBroadcaster() *Broadcaster {
+ return &Broadcaster{
+ subscribers: make(map[chan []byte]struct{}),
+ }
+}
+
+func (b *Broadcaster) Subscribe() chan []byte {
+ ch := make(chan []byte, 64)
+ b.mu.Lock()
+ b.subscribers[ch] = struct{}{}
+ b.mu.Unlock()
+ return ch
+}
+
+func (b *Broadcaster) Unsubscribe(ch chan []byte) {
+ b.mu.Lock()
+ delete(b.subscribers, ch)
+ b.mu.Unlock()
+ close(ch)
+}
+
+func (b *Broadcaster) Publish(data []byte) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ for ch := range b.subscribers {
+ select {
+ case ch <- data:
+ default:
+ }
+ }
+}
diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go
new file mode 100644
index 0000000..a77e877
--- /dev/null
+++ b/backend/internal/store/memory.go
@@ -0,0 +1,277 @@
+package store
+
+import (
+ "errors"
+ "math/rand"
+ "sync"
+ "time"
+
+ "yaum/internal/model"
+)
+
+type MemoryStore struct {
+ mu sync.RWMutex
+ services map[int]*model.Service
+ heartbeats map[int][]*model.Heartbeat
+ maintenances []model.Maintenance
+ nextSvcID int
+ nextHbID int
+ nextMtnID int
+}
+
+func NewMemoryStore() *MemoryStore {
+ return &MemoryStore{
+ services: make(map[int]*model.Service),
+ heartbeats: make(map[int][]*model.Heartbeat),
+ maintenances: make([]model.Maintenance, 0),
+ nextSvcID: 1,
+ nextHbID: 1,
+ nextMtnID: 1,
+ }
+}
+
+func (s *MemoryStore) SeedMockData() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := time.Now()
+ mockServices := []model.Service{
+ {Name: "Google", URL: "https://www.google.com", GroupName: "Geral", IntervalSeconds: 60, IsActive: true, CreatedAt: now},
+ {Name: "GitHub", URL: "https://www.github.com", GroupName: "Geral", IntervalSeconds: 60, IsActive: true, CreatedAt: now},
+ {Name: "JSONPlaceholder", URL: "https://jsonplaceholder.typicode.com/todos/1", GroupName: "APIs", IntervalSeconds: 60, IsActive: true, CreatedAt: now},
+ {Name: "API Local (falha esperada)", URL: "http://localhost:9999", GroupName: "APIs", IntervalSeconds: 60, IsActive: true, CreatedAt: now},
+ }
+
+ for _, svc := range mockServices {
+ svc.ID = s.nextSvcID
+ s.nextSvcID++
+ s.services[svc.ID] = &svc
+ s.heartbeats[svc.ID] = make([]*model.Heartbeat, 0)
+ s.seedHistory(svc.ID)
+ }
+}
+
+func (s *MemoryStore) seedHistory(serviceID int) {
+ now := time.Now()
+ for i := 29; i >= 0; i-- {
+ isUp := true
+ if rand.Intn(10) == 0 {
+ isUp = false
+ }
+
+ statusCode := 200
+ errMsg := ""
+ if !isUp {
+ statusCode = 0
+ errMsg = "connection timeout"
+ }
+
+ hb := &model.Heartbeat{
+ ID: s.nextHbID,
+ ServiceID: serviceID,
+ StatusCode: statusCode,
+ ResponseTimeMs: int64(50 + rand.Intn(450)),
+ IsUp: isUp,
+ ErrorMessage: errMsg,
+ TestedAt: now.Add(-time.Duration(i) * time.Minute),
+ }
+ s.nextHbID++
+ s.heartbeats[serviceID] = append(s.heartbeats[serviceID], hb)
+ }
+}
+
+func (s *MemoryStore) ListServices() ([]model.ServiceWithHeartbeat, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ result := make([]model.ServiceWithHeartbeat, 0, len(s.services))
+ for _, svc := range s.services {
+ swh := model.ServiceWithHeartbeat{Service: *svc}
+ if hbs := s.heartbeats[svc.ID]; len(hbs) > 0 {
+ swh.LastHeartbeat = hbs[len(hbs)-1]
+ }
+ result = append(result, swh)
+ }
+ return result, nil
+}
+
+func (s *MemoryStore) ListActiveServices() ([]model.Service, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var result []model.Service
+ for _, svc := range s.services {
+ if svc.IsActive {
+ result = append(result, *svc)
+ }
+ }
+ return result, nil
+}
+
+func (s *MemoryStore) AddService(svc model.Service) (*model.Service, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ svc.ID = s.nextSvcID
+ svc.IsActive = true
+ svc.CreatedAt = time.Now()
+ s.nextSvcID++
+ s.services[svc.ID] = &svc
+ s.heartbeats[svc.ID] = make([]*model.Heartbeat, 0)
+ return &svc, nil
+}
+
+func (s *MemoryStore) GetService(id int) (*model.Service, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ svc, ok := s.services[id]
+ if !ok {
+ return nil, nil
+ }
+ return svc, nil
+}
+
+func (s *MemoryStore) ToggleService(id int) (*model.Service, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ svc, ok := s.services[id]
+ if !ok {
+ return nil, errors.New("servico nao encontrado")
+ }
+ svc.IsActive = !svc.IsActive
+ return svc, nil
+}
+
+func (s *MemoryStore) UpdateService(id int, svc model.Service) (*model.Service, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ existing, ok := s.services[id]
+ if !ok {
+ return nil, errors.New("servico nao encontrado")
+ }
+
+ svc.ID = id
+ svc.IsActive = existing.IsActive
+ svc.CreatedAt = existing.CreatedAt
+ s.services[id] = &svc
+ return &svc, nil
+}
+
+func (s *MemoryStore) DeleteService(id int) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if _, ok := s.services[id]; !ok {
+ return errors.New("servico nao encontrado")
+ }
+ delete(s.services, id)
+ delete(s.heartbeats, id)
+ return nil
+}
+
+func (s *MemoryStore) AddHeartbeat(hb model.Heartbeat) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ hb.ID = s.nextHbID
+ s.nextHbID++
+ s.heartbeats[hb.ServiceID] = append(s.heartbeats[hb.ServiceID], &hb)
+
+ maxHistory := 500
+ if len(s.heartbeats[hb.ServiceID]) > maxHistory {
+ s.heartbeats[hb.ServiceID] = s.heartbeats[hb.ServiceID][len(s.heartbeats[hb.ServiceID])-maxHistory:]
+ }
+ return nil
+}
+
+func (s *MemoryStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ hbs := s.heartbeats[serviceID]
+ if len(hbs) > limit {
+ hbs = hbs[len(hbs)-limit:]
+ }
+ result := make([]model.Heartbeat, len(hbs))
+ for i, hb := range hbs {
+ result[i] = *hb
+ }
+ return result, nil
+}
+
+func (s *MemoryStore) GetLastHeartbeat(serviceID int) (*model.Heartbeat, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ hbs := s.heartbeats[serviceID]
+ if len(hbs) == 0 {
+ return nil, nil
+ }
+ return hbs[len(hbs)-1], nil
+}
+
+func (s *MemoryStore) GetActiveMaintenance(now time.Time) (*model.Maintenance, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ for _, m := range s.maintenances {
+ if m.IsActive && now.After(m.StartTime) && now.Before(m.EndTime) {
+ return &m, nil
+ }
+ }
+ return nil, nil
+}
+
+func (s *MemoryStore) GetServiceStats(serviceID int) (*model.ServiceStats, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ hbs := s.heartbeats[serviceID]
+ now := time.Now()
+
+ calc := func(since time.Duration) (total, up int, avgMs float64) {
+ cutoff := now.Add(-since)
+ var sum int64
+ for _, h := range hbs {
+ if h.TestedAt.Before(cutoff) {
+ continue
+ }
+ total++
+ if h.IsUp {
+ up++
+ }
+ sum += h.ResponseTimeMs
+ }
+ if total > 0 {
+ avgMs = float64(sum) / float64(total)
+ }
+ return
+ }
+
+ t24, u24, a24 := calc(24 * time.Hour)
+ t7, u7, a7 := calc(7 * 24 * time.Hour)
+ t30, u30, a30 := calc(30 * 24 * time.Hour)
+
+ pct := func(up, total int) float64 {
+ if total == 0 {
+ return 0
+ }
+ return float64(up) / float64(total) * 100
+ }
+
+ return &model.ServiceStats{
+ ServiceID: serviceID,
+ Uptime24h: pct(u24, t24),
+ Uptime7d: pct(u7, t7),
+ Uptime30d: pct(u30, t30),
+ AvgResponseMs24h: a24,
+ AvgResponseMs7d: a7,
+ AvgResponseMs30d: a30,
+ TotalChecks24h: t24,
+ TotalChecks7d: t7,
+ TotalChecks30d: t30,
+ }, nil
+}
diff --git a/backend/internal/store/postgres.go b/backend/internal/store/postgres.go
new file mode 100644
index 0000000..a8d072a
--- /dev/null
+++ b/backend/internal/store/postgres.go
@@ -0,0 +1,324 @@
+package store
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "yaum/internal/model"
+)
+
+type PostgresStore struct {
+ pool *pgxpool.Pool
+}
+
+func NewPostgresStore(ctx context.Context, connString string) (*PostgresStore, error) {
+ pool, err := pgxpool.New(ctx, connString)
+ if err != nil {
+ return nil, fmt.Errorf("erro ao conectar no postgres: %w", err)
+ }
+ if err := pool.Ping(ctx); err != nil {
+ return nil, fmt.Errorf("erro ao pingar postgres: %w", err)
+ }
+ return &PostgresStore{pool: pool}, nil
+}
+
+func (s *PostgresStore) Close() {
+ s.pool.Close()
+}
+
+// ---------------------------------------------------------------------------
+// Services
+// ---------------------------------------------------------------------------
+
+func (s *PostgresStore) ListServices() ([]model.ServiceWithHeartbeat, error) {
+ query := `
+ SELECT
+ s.id, s.name, s.url, s.group_name, s.interval_seconds, s.is_active, s.created_at,
+ h.id AS heartbeat_id,
+ h.status_code,
+ h.response_time_ms,
+ h.is_up,
+ h.error_message,
+ h.tested_at
+ FROM services s
+ LEFT JOIN LATERAL (
+ SELECT id, status_code, response_time_ms, is_up, error_message, tested_at
+ FROM heartbeats
+ WHERE service_id = s.id
+ ORDER BY tested_at DESC
+ LIMIT 1
+ ) h ON true
+ ORDER BY s.id
+ `
+
+ rows, err := s.pool.Query(context.Background(), query)
+ if err != nil {
+ return nil, fmt.Errorf("list services: %w", err)
+ }
+ defer rows.Close()
+
+ return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.ServiceWithHeartbeat, error) {
+ var (
+ swh model.ServiceWithHeartbeat
+ heartbeatID *int
+ statusCode *int
+ responseTimeMs *int64
+ isUp *bool
+ errorMessage *string
+ testedAt *time.Time
+ )
+ err := row.Scan(
+ &swh.ID, &swh.Name, &swh.URL, &swh.GroupName, &swh.IntervalSeconds, &swh.IsActive, &swh.CreatedAt,
+ &heartbeatID, &statusCode, &responseTimeMs, &isUp, &errorMessage, &testedAt,
+ )
+ if err != nil {
+ return swh, err
+ }
+ if heartbeatID != nil {
+ swh.LastHeartbeat = &model.Heartbeat{
+ ID: *heartbeatID,
+ ServiceID: swh.ID,
+ StatusCode: *statusCode,
+ ResponseTimeMs: *responseTimeMs,
+ IsUp: *isUp,
+ ErrorMessage: *errorMessage,
+ TestedAt: *testedAt,
+ }
+ }
+ return swh, nil
+ })
+}
+
+func (s *PostgresStore) ListActiveServices() ([]model.Service, error) {
+ query := `
+ SELECT id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at
+ FROM services
+ WHERE is_active = true
+ ORDER BY id
+ `
+
+ rows, err := s.pool.Query(context.Background(), query)
+ if err != nil {
+ return nil, fmt.Errorf("list active services: %w", err)
+ }
+ defer rows.Close()
+
+ return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.Service, error) {
+ var svc model.Service
+ err := row.Scan(&svc.ID, &svc.Name, &svc.URL, &svc.GroupName, &svc.IntervalSeconds, &svc.IsActive, &svc.DiscordWebhookURL, &svc.AlertEmail, &svc.KeywordToFind, &svc.CreatedAt)
+ return svc, err
+ })
+}
+
+func (s *PostgresStore) GetService(id int) (*model.Service, error) {
+ query := `
+ SELECT id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at
+ FROM services
+ WHERE id = $1
+ `
+ var svc model.Service
+ err := s.pool.QueryRow(context.Background(), query, id).
+ Scan(&svc.ID, &svc.Name, &svc.URL, &svc.GroupName, &svc.IntervalSeconds, &svc.IsActive, &svc.DiscordWebhookURL, &svc.AlertEmail, &svc.KeywordToFind, &svc.CreatedAt)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get service: %w", err)
+ }
+ return &svc, nil
+}
+
+func (s *PostgresStore) AddService(svc model.Service) (*model.Service, error) {
+ query := `
+ INSERT INTO services (name, url, group_name, interval_seconds, discord_webhook_url, alert_email, keyword_to_find)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING id, is_active, created_at
+ `
+ err := s.pool.QueryRow(context.Background(), query, svc.Name, svc.URL, svc.GroupName, svc.IntervalSeconds, svc.DiscordWebhookURL, svc.AlertEmail, svc.KeywordToFind).
+ Scan(&svc.ID, &svc.IsActive, &svc.CreatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("add service: %w", err)
+ }
+ return &svc, nil
+}
+
+func (s *PostgresStore) ToggleService(id int) (*model.Service, error) {
+ query := `
+ UPDATE services
+ SET is_active = NOT is_active
+ WHERE id = $1
+ RETURNING id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at
+ `
+ var svc model.Service
+ err := s.pool.QueryRow(context.Background(), query, id).
+ Scan(&svc.ID, &svc.Name, &svc.URL, &svc.GroupName, &svc.IntervalSeconds, &svc.IsActive, &svc.DiscordWebhookURL, &svc.AlertEmail, &svc.KeywordToFind, &svc.CreatedAt)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, fmt.Errorf("servico nao encontrado")
+ }
+ return nil, fmt.Errorf("toggle service: %w", err)
+ }
+ return &svc, nil
+}
+
+func (s *PostgresStore) UpdateService(id int, svc model.Service) (*model.Service, error) {
+ query := `
+ UPDATE services
+ SET name = $1, url = $2, group_name = $3, interval_seconds = $4, discord_webhook_url = $5, alert_email = $6, keyword_to_find = $7
+ WHERE id = $8
+ RETURNING id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at
+ `
+ var updated model.Service
+ err := s.pool.QueryRow(context.Background(), query,
+ svc.Name, svc.URL, svc.GroupName, svc.IntervalSeconds, svc.DiscordWebhookURL, svc.AlertEmail, svc.KeywordToFind, id,
+ ).Scan(&updated.ID, &updated.Name, &updated.URL, &updated.GroupName, &updated.IntervalSeconds, &updated.IsActive, &updated.DiscordWebhookURL, &updated.AlertEmail, &updated.KeywordToFind, &updated.CreatedAt)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, fmt.Errorf("servico nao encontrado")
+ }
+ return nil, fmt.Errorf("update service: %w", err)
+ }
+ return &updated, nil
+}
+
+func (s *PostgresStore) DeleteService(id int) error {
+ tag, err := s.pool.Exec(context.Background(), "DELETE FROM services WHERE id = $1", id)
+ if err != nil {
+ return fmt.Errorf("delete service: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("servico nao encontrado")
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Heartbeats
+// ---------------------------------------------------------------------------
+
+func (s *PostgresStore) AddHeartbeat(hb model.Heartbeat) error {
+ query := `
+ INSERT INTO heartbeats (service_id, status_code, response_time_ms, is_up, error_message, tested_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `
+ _, err := s.pool.Exec(context.Background(), query,
+ hb.ServiceID, hb.StatusCode, hb.ResponseTimeMs, hb.IsUp, hb.ErrorMessage, hb.TestedAt)
+ if err != nil {
+ return fmt.Errorf("add heartbeat: %w", err)
+ }
+ return nil
+}
+
+func (s *PostgresStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat, error) {
+ query := `
+ SELECT id, service_id, status_code, response_time_ms, is_up, error_message, tested_at
+ FROM heartbeats
+ WHERE service_id = $1
+ ORDER BY tested_at DESC
+ LIMIT $2
+ `
+ rows, err := s.pool.Query(context.Background(), query, serviceID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("get history: %w", err)
+ }
+ defer rows.Close()
+
+ return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.Heartbeat, error) {
+ var hb model.Heartbeat
+ err := row.Scan(&hb.ID, &hb.ServiceID, &hb.StatusCode, &hb.ResponseTimeMs, &hb.IsUp, &hb.ErrorMessage, &hb.TestedAt)
+ return hb, err
+ })
+}
+
+func (s *PostgresStore) GetLastHeartbeat(serviceID int) (*model.Heartbeat, error) {
+ query := `
+ SELECT id, service_id, status_code, response_time_ms, is_up, error_message, tested_at
+ FROM heartbeats
+ WHERE service_id = $1
+ ORDER BY tested_at DESC
+ LIMIT 1
+ `
+ hb := &model.Heartbeat{}
+ err := s.pool.QueryRow(context.Background(), query, serviceID).
+ Scan(&hb.ID, &hb.ServiceID, &hb.StatusCode, &hb.ResponseTimeMs, &hb.IsUp, &hb.ErrorMessage, &hb.TestedAt)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get last heartbeat: %w", err)
+ }
+ return hb, nil
+}
+
+func (s *PostgresStore) GetServiceStats(serviceID int) (*model.ServiceStats, error) {
+ query := `
+ SELECT
+ COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '24 hours' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '24 hours' AND is_up THEN 1 ELSE 0 END), 0),
+ COALESCE(AVG(response_time_ms) FILTER (WHERE tested_at >= NOW() - INTERVAL '24 hours'), 0),
+ COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '7 days' AND is_up THEN 1 ELSE 0 END), 0),
+ COALESCE(AVG(response_time_ms) FILTER (WHERE tested_at >= NOW() - INTERVAL '7 days'), 0),
+ COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '30 days' AND is_up THEN 1 ELSE 0 END), 0),
+ COALESCE(AVG(response_time_ms) FILTER (WHERE tested_at >= NOW() - INTERVAL '30 days'), 0)
+ FROM heartbeats
+ WHERE service_id = $1
+ `
+ var (
+ t24, u24 int32
+ a24 float64
+ t7, u7 int32
+ a7 float64
+ t30, u30 int32
+ a30 float64
+ )
+ err := s.pool.QueryRow(context.Background(), query, serviceID).
+ Scan(&t24, &u24, &a24, &t7, &u7, &a7, &t30, &u30, &a30)
+ if err != nil {
+ return nil, fmt.Errorf("get service stats: %w", err)
+ }
+
+ pct := func(up, total int32) float64 {
+ if total == 0 {
+ return 0
+ }
+ return float64(up) / float64(total) * 100
+ }
+
+ return &model.ServiceStats{
+ ServiceID: serviceID,
+ Uptime24h: pct(u24, t24),
+ Uptime7d: pct(u7, t7),
+ Uptime30d: pct(u30, t30),
+ AvgResponseMs24h: a24,
+ AvgResponseMs7d: a7,
+ AvgResponseMs30d: a30,
+ TotalChecks24h: int(t24),
+ TotalChecks7d: int(t7),
+ TotalChecks30d: int(t30),
+ }, nil
+}
+
+func (s *PostgresStore) GetActiveMaintenance(now time.Time) (*model.Maintenance, error) {
+ query := `
+ SELECT id, title, start_time, end_time, is_active
+ FROM maintenances
+ WHERE is_active = true AND start_time <= $1 AND end_time > $1
+ LIMIT 1
+ `
+ var m model.Maintenance
+ err := s.pool.QueryRow(context.Background(), query, now).
+ Scan(&m.ID, &m.Title, &m.StartTime, &m.EndTime, &m.IsActive)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get active maintenance: %w", err)
+ }
+ return &m, nil
+}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
new file mode 100644
index 0000000..e5e05f6
--- /dev/null
+++ b/backend/internal/store/store.go
@@ -0,0 +1,23 @@
+package store
+
+import (
+ "time"
+
+ "yaum/internal/model"
+)
+
+type Store interface {
+ ListServices() ([]model.ServiceWithHeartbeat, error)
+ ListActiveServices() ([]model.Service, error)
+ GetService(int) (*model.Service, error)
+ AddService(model.Service) (*model.Service, error)
+ UpdateService(int, model.Service) (*model.Service, error)
+ ToggleService(int) (*model.Service, error)
+ DeleteService(int) error
+ AddHeartbeat(model.Heartbeat) error
+ GetHistory(int, int) ([]model.Heartbeat, error)
+ GetLastHeartbeat(int) (*model.Heartbeat, error)
+ GetServiceStats(int) (*model.ServiceStats, error)
+
+ GetActiveMaintenance(now time.Time) (*model.Maintenance, error)
+}