aboutsummaryrefslogtreecommitdiff
path: root/backend/internal/store
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/store
downloadyaum-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.go277
-rw-r--r--backend/internal/store/postgres.go324
-rw-r--r--backend/internal/store/store.go23
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)
+}