From 3a089e7c1d2142b32a7cb1fb052aca121992ba2c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 27 Jun 2026 10:58:43 -0400 Subject: [PATCH] feat(store): detect overlapping maintenance windows Warn via log when creating a maintenance window that overlaps with an existing one for the same monitor, parent group, or global scope. Handles both timed and indefinite windows. --- internal/store/sqlstore.go | 42 ++++++++++++++ internal/store/sqlstore_test.go | 97 ++++++++++++++++++++++++++++++++ internal/store/store.go | 1 + internal/store/storetest/mock.go | 4 ++ internal/tui/tab_maint.go | 7 ++- 5 files changed, 150 insertions(+), 1 deletion(-) diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index b6a96cb..1ba7c3d 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -652,6 +652,48 @@ func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m return windows, rows.Err() } +func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) { + var timeClause string + var args []interface{} + + if endTime.IsZero() { + timeClause = "(end_time IS NULL OR end_time > ?)" + args = append(args, startTime) + } else { + timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?" + args = append(args, startTime, endTime) + } + + var scopeClause string + if monitorID == 0 { + scopeClause = "1=1" + } else { + scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))" + args = append(args, monitorID, monitorID) + } + + query := fmt.Sprintf( + "SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time", + timeClause, scopeClause, + ) + + rows, err := s.db.QueryContext(ctx, s.q(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var windows []models.MaintenanceWindow + for rows.Next() { + mw, err := s.scanMaintenanceWindow(rows) + if err != nil { + return nil, err + } + windows = append(windows, mw) + } + return windows, rows.Err() +} + func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error { if mw.StartTime.IsZero() { mw.StartTime = time.Now() diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go index 85467c3..8174b01 100644 --- a/internal/store/sqlstore_test.go +++ b/internal/store/sqlstore_test.go @@ -509,6 +509,103 @@ func TestPruneExpiredMaintenanceWindows(t *testing.T) { } } +func TestGetOverlappingMaintenanceWindows(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30} + if err := s.AddSite(ctx, site); err != nil { + t.Fatalf("AddSite: %v", err) + } + + now := time.Now() + + active := models.MaintenanceWindow{ + MonitorID: 1, + Title: "Deploy v2", + Type: "maintenance", + StartTime: now.Add(-30 * time.Minute), + EndTime: now.Add(30 * time.Minute), + } + if err := s.AddMaintenanceWindow(ctx, active); err != nil { + t.Fatalf("AddMaintenanceWindow: %v", err) + } + + ended := models.MaintenanceWindow{ + MonitorID: 1, + Title: "Old deploy", + Type: "maintenance", + StartTime: now.Add(-3 * time.Hour), + EndTime: now.Add(-2 * time.Hour), + } + if err := s.AddMaintenanceWindow(ctx, ended); err != nil { + t.Fatalf("AddMaintenanceWindow: %v", err) + } + + t.Run("same monitor overlaps", func(t *testing.T) { + overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(overlaps) != 1 { + t.Fatalf("expected 1 overlap, got %d", len(overlaps)) + } + if overlaps[0].Title != "Deploy v2" { + t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title) + } + }) + + t.Run("different monitor no overlap", func(t *testing.T) { + overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(overlaps) != 0 { + t.Errorf("expected 0 overlaps, got %d", len(overlaps)) + } + }) + + t.Run("global window overlaps all", func(t *testing.T) { + global := models.MaintenanceWindow{ + MonitorID: 0, + Title: "Global freeze", + Type: "maintenance", + StartTime: now.Add(-10 * time.Minute), + EndTime: now.Add(2 * time.Hour), + } + if err := s.AddMaintenanceWindow(ctx, global); err != nil { + t.Fatalf("AddMaintenanceWindow: %v", err) + } + overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(overlaps) != 2 { + t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps)) + } + }) + + t.Run("indefinite window overlaps", func(t *testing.T) { + overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(overlaps) < 1 { + t.Error("expected at least 1 overlap for indefinite window") + } + }) + + t.Run("ended window excluded", func(t *testing.T) { + overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(overlaps) != 0 { + t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps)) + } + }) +} + // ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a // restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext. func TestImportData_EncryptsAlertSettings(t *testing.T) { diff --git a/internal/store/store.go b/internal/store/store.go index 861b3dc..c52c2d9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -67,6 +67,7 @@ type Store interface { // Maintenance Windows GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) + GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error EndMaintenanceWindow(ctx context.Context, id int) error DeleteMaintenanceWindow(ctx context.Context, id int) error diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 759fbd2..6027a48 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -229,6 +229,10 @@ func (m *BaseMock) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m return nil, nil } +func (m *BaseMock) GetOverlappingMaintenanceWindows(_ context.Context, _ int, _, _ time.Time) ([]models.MaintenanceWindow, error) { + return nil, nil +} + func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error { return nil } diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index bc732cb..dcedb9e 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -256,6 +256,11 @@ func (m *Model) submitMaintForm() tea.Cmd { st := m.store m.state = stateDashboard return writeCmd("Add maintenance window", func() error { - return st.AddMaintenanceWindow(context.Background(), mw) + ctx := context.Background() + overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime) + if len(overlaps) > 0 { + _ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title)) + } + return st.AddMaintenanceWindow(ctx, mw) }) }