From 6cc0bfd1d79074df790272b8091b1f0226d14283 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Fri, 29 May 2026 15:57:37 -0300 Subject: feat: upload project Signed-off-by: zwlucas --- backend/.env | 7 + backend/.env.example | 7 + backend/cmd/server/main.go | 109 ++++++++++++ backend/config.json | 24 +++ backend/go.mod | 16 ++ backend/go.sum | 36 ++++ backend/internal/alerts/alerts.go | 113 +++++++++++++ backend/internal/alerts/smtp.go | 85 ++++++++++ backend/internal/api/auth.go | 144 ++++++++++++++++ backend/internal/api/handler.go | 243 +++++++++++++++++++++++++++ backend/internal/api/router.go | 71 ++++++++ backend/internal/api/sse.go | 57 +++++++ backend/internal/config/config.go | 125 ++++++++++++++ backend/internal/model/types.go | 52 ++++++ backend/internal/monitor/checker.go | 86 ++++++++++ backend/internal/monitor/worker.go | 148 ++++++++++++++++ backend/internal/sse/broadcaster.go | 42 +++++ backend/internal/store/memory.go | 277 ++++++++++++++++++++++++++++++ backend/internal/store/postgres.go | 324 ++++++++++++++++++++++++++++++++++++ backend/internal/store/store.go | 23 +++ backend/migrations/schema.sql | 34 ++++ backend/yaum-server.exe | Bin 0 -> 16090112 bytes 22 files changed, 2023 insertions(+) create mode 100644 backend/.env create mode 100644 backend/.env.example create mode 100644 backend/cmd/server/main.go create mode 100644 backend/config.json create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/alerts/alerts.go create mode 100644 backend/internal/alerts/smtp.go create mode 100644 backend/internal/api/auth.go create mode 100644 backend/internal/api/handler.go create mode 100644 backend/internal/api/router.go create mode 100644 backend/internal/api/sse.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/model/types.go create mode 100644 backend/internal/monitor/checker.go create mode 100644 backend/internal/monitor/worker.go create mode 100644 backend/internal/sse/broadcaster.go create mode 100644 backend/internal/store/memory.go create mode 100644 backend/internal/store/postgres.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/migrations/schema.sql create mode 100644 backend/yaum-server.exe (limited to 'backend') diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..5138821 --- /dev/null +++ b/backend/.env @@ -0,0 +1,7 @@ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# Para producao, use o hash bcrypt pre-computado: +# ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" suasenha | tr -d ':\n' | sed 's/$2y/$2a/') + +JWT_SECRET= diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5138821 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# Para producao, use o hash bcrypt pre-computado: +# ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" suasenha | tr -d ':\n' | sed 's/$2y/$2a/') + +JWT_SECRET= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..f734885 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + + "os/signal" + "syscall" + + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" + + "yaum/internal/alerts" + "yaum/internal/api" + "yaum/internal/config" + "yaum/internal/monitor" + "yaum/internal/sse" + "yaum/internal/store" +) + +func main() { + godotenv.Load() + + cfgPath := os.Getenv("CONFIG_PATH") + cfg, err := config.Load(cfgPath) + if err != nil { + log.Fatalf("config: %v", err) + } + + passwordHash := cfg.AdminPasswordHash() + if passwordHash == "" { + if pw := os.Getenv("ADMIN_PASSWORD"); pw != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("erro ao gerar hash da senha: %v", err) + } + passwordHash = string(hash) + } + } + if passwordHash == "" { + log.Fatal("ADMIN_PASSWORD_HASH ou ADMIN_PASSWORD nao definido — defina no .env ou config.json") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var st store.Store + + if dbURL := cfg.DatabaseURL(); dbURL != "" { + log.Println("conectando ao postgres...") + pg, err := store.NewPostgresStore(ctx, dbURL) + if err != nil { + log.Fatalf("banco: %v", err) + } + defer pg.Close() + st = pg + log.Println("conectado ao postgres") + } else { + log.Println("database.url vazio — usando store em memoria") + ms := store.NewMemoryStore() + ms.SeedMockData() + st = ms + } + + broadcaster := sse.NewBroadcaster() + + worker := monitor.NewWorker(st, cfg.CheckInterval(), cfg.CheckTimeout(), broadcaster) + if cfg.SMTP.From != "" { + smtpCfg := &alerts.SMTPConfig{ + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + From: cfg.SMTP.From, + } + worker.WithSMTP(smtpCfg) + } + handler := api.NewHandler(st, int(cfg.CheckTimeout().Seconds())) + streamHandler := api.NewStreamHandler(broadcaster) + authHandler := api.NewAuthHandler( + cfg.AdminUsername(), + passwordHash, + cfg.JWTSecret(), + ) + + server := &http.Server{ + Addr: cfg.ServerAddr(), + Handler: api.NewRouter(handler, streamHandler, authHandler), + } + + go worker.Start(ctx) + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + log.Printf("sinal recebido: %s. desligando...", sig) + worker.Stop() + server.Shutdown(context.Background()) + }() + + log.Printf("yaum rodando em %s", cfg.ServerAddr()) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("erro ao iniciar servidor: %v", err) + } + log.Println("servidor encerrado com sucesso") +} diff --git a/backend/config.json b/backend/config.json new file mode 100644 index 0000000..4d2afdc --- /dev/null +++ b/backend/config.json @@ -0,0 +1,24 @@ +{ + "server": { + "addr": ":8080" + }, + "database": { + "url": "" + }, + "monitor": { + "interval": "60s", + "timeout": "10s" + }, + "smtp": { + "host": "", + "port": 587, + "username": "", + "password": "", + "from": "" + }, + "auth": { + "admin_username": "admin", + "admin_password_hash": "", + "jwt_secret": "" + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..68f40b1 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,16 @@ +module yaum + +go 1.25.0 + +require github.com/jackc/pgx/v5 v5.9.2 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..68f0bf7 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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( + ` + + + %s +`, + 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) +} diff --git a/backend/migrations/schema.sql b/backend/migrations/schema.sql new file mode 100644 index 0000000..eba89db --- /dev/null +++ b/backend/migrations/schema.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS services ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + url TEXT NOT NULL, + group_name VARCHAR(255) NOT NULL DEFAULT 'Geral', + interval_seconds INTEGER NOT NULL DEFAULT 60, + is_active BOOLEAN NOT NULL DEFAULT true, + discord_webhook_url TEXT NOT NULL DEFAULT '', + alert_email TEXT NOT NULL DEFAULT '', + keyword_to_find TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS heartbeats ( + id SERIAL PRIMARY KEY, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + status_code INTEGER NOT NULL DEFAULT 0, + response_time_ms BIGINT NOT NULL DEFAULT 0, + is_up BOOLEAN NOT NULL DEFAULT false, + error_message TEXT NOT NULL DEFAULT '', + tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_heartbeats_service_id ON heartbeats(service_id); +CREATE INDEX IF NOT EXISTS idx_heartbeats_tested_at ON heartbeats(tested_at DESC); +CREATE INDEX IF NOT EXISTS idx_services_is_active ON services(is_active); + +CREATE TABLE IF NOT EXISTS maintenances ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true +); diff --git a/backend/yaum-server.exe b/backend/yaum-server.exe new file mode 100644 index 0000000..c578706 Binary files /dev/null and b/backend/yaum-server.exe differ -- cgit v1.2.3