diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-05-29 18:57:37 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-05-29 18:57:37 +0000 |
| commit | 6cc0bfd1d79074df790272b8091b1f0226d14283 (patch) | |
| tree | cab18b0f708cf3b0eaeeeb98e2cf81459365b33e /backend/internal/store | |
| download | yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip | |
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'backend/internal/store')
| -rw-r--r-- | backend/internal/store/memory.go | 277 | ||||
| -rw-r--r-- | backend/internal/store/postgres.go | 324 | ||||
| -rw-r--r-- | backend/internal/store/store.go | 23 |
3 files changed, 624 insertions, 0 deletions
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) +} |