Skip to content

Commit ce766fc

Browse files
committed
feat: improve notifications settings
1 parent b3a8fbf commit ce766fc

11 files changed

Lines changed: 589 additions & 162 deletions

internal/api/handlers_status_pages_branding_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func TestBranding_SetFaviconURL(t *testing.T) {
3838
page, _ := store.GetStatusPageBySlug("favicon-set")
3939
if page == nil {
4040
t.Fatal("Page not found after save")
41+
return
4142
}
4243
if page.FaviconURL != "https://example.com/favicon.ico" {
4344
t.Errorf("Expected faviconUrl 'https://example.com/favicon.ico', got '%s'", page.FaviconURL)

internal/api/handlers_status_pages_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ func TestToggle_UpsertCreatesNewPage(t *testing.T) {
391391
p, _ := store.GetStatusPageBySlug("brand-new")
392392
if p == nil {
393393
t.Fatal("Expected page to be created via upsert")
394+
return
394395
}
395396
if p.Title != "Brand New" {
396397
t.Errorf("Expected title 'Brand New', got '%s'", p.Title)

internal/api/handlers_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func TestPauseMonitor(t *testing.T) {
151151
}
152152
if m == nil {
153153
t.Fatal("Monitor m1 not found in DB")
154+
return
154155
}
155156
if m.Active {
156157
t.Error("Monitor should be inactive after pause")
@@ -200,6 +201,7 @@ func TestResumeMonitor(t *testing.T) {
200201
}
201202
if m == nil {
202203
t.Fatal("Monitor m1 not found in DB")
204+
return
203205
}
204206
if !m.Active {
205207
t.Error("Monitor should be active after resume")
@@ -861,9 +863,11 @@ func TestCreateMonitor_WithRequestConfig(t *testing.T) {
861863
}
862864
if found == nil {
863865
t.Fatal("Monitor 'RC Create Test' not found in DB")
866+
return
864867
}
865868
if found.RequestConfig == nil {
866869
t.Fatal("Expected RequestConfig to be persisted")
870+
return
867871
}
868872
if found.RequestConfig.Method != "POST" {
869873
t.Errorf("Expected method POST, got %s", found.RequestConfig.Method)
@@ -923,9 +927,11 @@ func TestUpdateMonitor_WithRequestConfig(t *testing.T) {
923927
}
924928
if found == nil {
925929
t.Fatal("Monitor not found in DB")
930+
return
926931
}
927932
if found.RequestConfig == nil {
928933
t.Fatal("Expected RequestConfig to be persisted after update")
934+
return
929935
}
930936
if found.RequestConfig.Method != "HEAD" {
931937
t.Errorf("Expected method HEAD, got %s", found.RequestConfig.Method)

internal/db/store_incidents_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ func TestIncidentWithNewFields(t *testing.T) {
132132
}
133133
if incident == nil {
134134
t.Fatal("Incident not found")
135+
return
135136
}
136137
if incident.Source != "auto" {
137138
t.Errorf("Source mismatch: got %s, want auto", incident.Source)

internal/db/store_monitors_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func TestMonitorCRUD(t *testing.T) {
4949
}
5050
if found == nil {
5151
t.Fatal("Monitor m1 not found")
52+
return
5253
}
5354
if found.Name != "Updated M1" || found.Interval != 120 {
5455
t.Error("Update verification failed")
@@ -427,6 +428,7 @@ func TestSetMonitorActive_PauseMonitor(t *testing.T) {
427428
}
428429
if found == nil {
429430
t.Fatal("Monitor not found")
431+
return
430432
}
431433
if !found.Active {
432434
t.Error("Monitor should be active initially")
@@ -483,6 +485,7 @@ func TestSetMonitorActive_ResumeMonitor(t *testing.T) {
483485
}
484486
if found == nil {
485487
t.Fatal("Monitor not found")
488+
return
486489
}
487490
if !found.Active {
488491
t.Error("Monitor should be active after resume")
@@ -706,6 +709,7 @@ func TestSetMonitorActive_PausePreservesOtherFields(t *testing.T) {
706709
}
707710
if found == nil {
708711
t.Fatal("Monitor not found")
712+
return
709713
}
710714
if found.Name != "Preserve Test" {
711715
t.Errorf("Name changed unexpectedly: %s", found.Name)
@@ -791,6 +795,7 @@ func TestGetDailyUptimeStats_WithChecks(t *testing.T) {
791795
}
792796
if todayStat == nil {
793797
t.Fatalf("Today's stats not found in results. Looking for %s", todayStr)
798+
return
794799
}
795800
if todayStat.Total != 10 {
796801
t.Errorf("Expected 10 total checks today, got %d", todayStat.Total)
@@ -924,6 +929,7 @@ func TestMonitor_PerMonitorOverrides(t *testing.T) {
924929
}
925930
if found == nil {
926931
t.Fatal("Monitor not found")
932+
return
927933
}
928934
if found.ConfirmationThreshold == nil || *found.ConfirmationThreshold != 5 {
929935
t.Errorf("Expected ConfirmationThreshold=5, got %v", found.ConfirmationThreshold)
@@ -959,6 +965,7 @@ func TestMonitor_PerMonitorOverrides(t *testing.T) {
959965
}
960966
if found == nil {
961967
t.Fatal("Monitor not found")
968+
return
962969
}
963970
if found.ConfirmationThreshold != nil {
964971
t.Errorf("Expected ConfirmationThreshold=nil, got %v", *found.ConfirmationThreshold)
@@ -996,6 +1003,7 @@ func TestMonitor_PerMonitorOverrides(t *testing.T) {
9961003
}
9971004
if found == nil {
9981005
t.Fatal("Monitor not found")
1006+
return
9991007
}
10001008
if found.ConfirmationThreshold == nil || *found.ConfirmationThreshold != 7 {
10011009
t.Errorf("Expected ConfirmationThreshold=7, got %v", found.ConfirmationThreshold)
@@ -1035,6 +1043,7 @@ func TestMonitor_PerMonitorOverrides(t *testing.T) {
10351043
}
10361044
if found == nil {
10371045
t.Fatal("Monitor not found")
1046+
return
10381047
}
10391048
if found.ConfirmationThreshold != nil {
10401049
t.Errorf("Expected ConfirmationThreshold=nil after clear, got %v", *found.ConfirmationThreshold)
@@ -1074,6 +1083,7 @@ func TestMonitor_PerMonitorOverrides(t *testing.T) {
10741083
}
10751084
if found == nil {
10761085
t.Fatal("Monitor not found")
1086+
return
10771087
}
10781088
if found.ConfirmationThreshold == nil || *found.ConfirmationThreshold != 8 {
10791089
t.Errorf("Expected ConfirmationThreshold=8, got %v", found.ConfirmationThreshold)
@@ -1109,6 +1119,7 @@ func TestMonitor_LatencyThresholdRoundtrip(t *testing.T) {
11091119
}
11101120
if found == nil {
11111121
t.Fatal("Monitor not found")
1122+
return
11121123
}
11131124
if found.LatencyThreshold == nil || *found.LatencyThreshold != 2000 {
11141125
t.Errorf("Expected LatencyThreshold=2000, got %v", found.LatencyThreshold)
@@ -1137,6 +1148,7 @@ func TestMonitor_LatencyThresholdRoundtrip(t *testing.T) {
11371148
}
11381149
if found == nil {
11391150
t.Fatal("Monitor not found")
1151+
return
11401152
}
11411153
if found.LatencyThreshold != nil {
11421154
t.Errorf("Expected LatencyThreshold=nil, got %v", *found.LatencyThreshold)
@@ -1169,6 +1181,7 @@ func TestMonitor_LatencyThresholdRoundtrip(t *testing.T) {
11691181
}
11701182
if found == nil {
11711183
t.Fatal("Monitor not found")
1184+
return
11721185
}
11731186
if found.LatencyThreshold == nil || *found.LatencyThreshold != 500 {
11741187
t.Errorf("Expected LatencyThreshold=500, got %v", found.LatencyThreshold)
@@ -1202,6 +1215,7 @@ func TestMonitor_LatencyThresholdRoundtrip(t *testing.T) {
12021215
}
12031216
if found == nil {
12041217
t.Fatal("Monitor not found")
1218+
return
12051219
}
12061220
if found.LatencyThreshold != nil {
12071221
t.Errorf("Expected LatencyThreshold=nil after clear, got %v", *found.LatencyThreshold)
@@ -1250,9 +1264,11 @@ func TestMonitorCRUD_RequestConfig(t *testing.T) {
12501264
}
12511265
if found == nil {
12521266
t.Fatal("Monitor m-rc1 not found")
1267+
return
12531268
}
12541269
if found.RequestConfig == nil {
12551270
t.Fatal("Expected RequestConfig to be non-nil")
1271+
return
12561272
}
12571273
rc := found.RequestConfig
12581274
if rc.Method != "POST" {
@@ -1302,9 +1318,11 @@ func TestMonitorCRUD_RequestConfig(t *testing.T) {
13021318
}
13031319
if found == nil {
13041320
t.Fatal("Monitor m-rc1 not found after update")
1321+
return
13051322
}
13061323
if found.RequestConfig == nil {
13071324
t.Fatal("Expected RequestConfig to be non-nil after update")
1325+
return
13081326
}
13091327
if found.RequestConfig.Method != "HEAD" {
13101328
t.Errorf("Expected method HEAD after update, got %s", found.RequestConfig.Method)
@@ -1329,6 +1347,7 @@ func TestMonitorCRUD_RequestConfig(t *testing.T) {
13291347
}
13301348
if found == nil {
13311349
t.Fatal("Monitor m-rc1 not found after nil update")
1350+
return
13321351
}
13331352
if found.RequestConfig != nil {
13341353
t.Errorf("Expected RequestConfig to be nil after clearing, got %+v", found.RequestConfig)

internal/db/store_multidb_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ func TestMultiDB_Sessions(t *testing.T) {
160160
}
161161
if session == nil {
162162
t.Fatal("Expected session, got nil")
163+
return
163164
}
164165
if session.UserID != user.ID {
165166
t.Errorf("Expected user ID %d, got %d", user.ID, session.UserID)
@@ -388,6 +389,7 @@ func TestMultiDB_StatusPages(t *testing.T) {
388389
}
389390
if page == nil {
390391
t.Fatal("Expected page, got nil")
392+
return
391393
}
392394
if page.Title != "Test Status Page" {
393395
t.Errorf("Expected title 'Test Status Page', got '%s'", page.Title)

internal/db/store_status_pages_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func TestStatusPages(t *testing.T) {
2929
}
3030
if p == nil {
3131
t.Fatal("Custom page not found")
32+
return
3233
}
3334
if p.Title != "Custom Page" {
3435
t.Error("Title mismatch")
@@ -61,6 +62,7 @@ func TestStatusPages_DefaultSeedDisabled(t *testing.T) {
6162
}
6263
if p == nil {
6364
t.Fatal("Default page missing")
65+
return
6466
}
6567
if p.Enabled {
6668
t.Error("Default seeded page should have enabled=false")
@@ -95,6 +97,7 @@ func TestStatusPages_UpsertAllCombinations(t *testing.T) {
9597
}
9698
if p == nil {
9799
t.Fatalf("Page %s not found", tc.slug)
100+
return
98101
}
99102
if p.Public != tc.public {
100103
t.Errorf("Page %s: expected public=%v, got %v", tc.slug, tc.public, p.Public)
@@ -255,6 +258,7 @@ func TestStatusPages_WithGroupID(t *testing.T) {
255258
p, _ := s.GetStatusPageBySlug("group-page")
256259
if p == nil {
257260
t.Fatal("Page not found")
261+
return
258262
}
259263
if p.GroupID == nil || *p.GroupID != "g-sp-test" {
260264
t.Error("Expected groupId='g-sp-test'")

internal/uptime/manager_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,7 @@ func TestManager_SyncWithRequestConfig(t *testing.T) {
18631863
gotRC := running.GetRequestConfig()
18641864
if gotRC == nil {
18651865
t.Fatal("Expected RequestConfig to be non-nil")
1866+
return
18661867
}
18671868
if gotRC.Method != "POST" {
18681869
t.Errorf("Expected method POST, got %s", gotRC.Method)
@@ -1892,6 +1893,7 @@ func TestManager_SyncWithRequestConfig(t *testing.T) {
18921893
gotRC = running.GetRequestConfig()
18931894
if gotRC == nil {
18941895
t.Fatal("Expected RequestConfig to be non-nil after update")
1896+
return
18951897
}
18961898
if gotRC.Method != "HEAD" {
18971899
t.Errorf("Expected method HEAD after update, got %s", gotRC.Method)

internal/uptime/monitor.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Monitor struct {
4242

4343
lastNotifiedAt map[string]time.Time // per-event-type cooldown tracking
4444
isFlapping bool // current flap state
45+
flapStabilizedAt time.Time // when flapping last stopped (grace period)
4546

4647
// Flap detection settings
4748
flapDetectionEnabled bool
@@ -342,17 +343,35 @@ func (m *Monitor) IsConfirmedDegraded() bool {
342343

343344
// ShouldNotify checks whether a notification for the given event type is allowed
344345
// (not suppressed by cooldown). Returns true if notification should be sent.
346+
// Flapping and stabilized share a cooldown to prevent rapid cycling between them.
345347
func (m *Monitor) ShouldNotify(eventType string) bool {
346348
m.mu.RLock()
347349
defer m.mu.RUnlock()
348350
if m.cooldownMinutes <= 0 {
349351
return true
350352
}
353+
cooldown := time.Duration(m.cooldownMinutes) * time.Minute
354+
355+
// Flapping and stabilized share a cooldown — check whichever fired most recently
356+
if eventType == "flapping" || eventType == "stabilized" {
357+
var latest time.Time
358+
if t, ok := m.lastNotifiedAt["flapping"]; ok && t.After(latest) {
359+
latest = t
360+
}
361+
if t, ok := m.lastNotifiedAt["stabilized"]; ok && t.After(latest) {
362+
latest = t
363+
}
364+
if latest.IsZero() {
365+
return true
366+
}
367+
return time.Since(latest) >= cooldown
368+
}
369+
351370
lastTime, exists := m.lastNotifiedAt[eventType]
352371
if !exists {
353372
return true
354373
}
355-
return time.Since(lastTime) >= time.Duration(m.cooldownMinutes)*time.Minute
374+
return time.Since(lastTime) >= cooldown
356375
}
357376

358377
// MarkNotified records the current time as when a notification was sent for the given event type.
@@ -410,6 +429,17 @@ func (m *Monitor) ComputeFlapping() (isFlapping bool, changed bool) {
410429
wasFlapping := m.isFlapping
411430

412431
if transitionPercent >= m.flapThresholdPercent {
432+
// Grace period: don't re-enter flapping within cooldown window of stabilization.
433+
// This prevents rapid flapping→stabilized→flapping cycling.
434+
if !wasFlapping && !m.flapStabilizedAt.IsZero() {
435+
grace := time.Duration(m.cooldownMinutes) * time.Minute
436+
if grace < 5*time.Minute {
437+
grace = 5 * time.Minute // minimum 5-minute grace period
438+
}
439+
if time.Since(m.flapStabilizedAt) < grace {
440+
return false, false // still in grace period, suppress re-entry
441+
}
442+
}
413443
m.isFlapping = true
414444
} else {
415445
// Use hysteresis: only stop flapping at a lower threshold (80% of start threshold)
@@ -419,7 +449,14 @@ func (m *Monitor) ComputeFlapping() (isFlapping bool, changed bool) {
419449
}
420450
}
421451

422-
return m.isFlapping, m.isFlapping != wasFlapping
452+
if m.isFlapping != wasFlapping {
453+
if !m.isFlapping {
454+
// Record when we stopped flapping for grace period
455+
m.flapStabilizedAt = time.Now()
456+
}
457+
return m.isFlapping, true
458+
}
459+
return m.isFlapping, false
423460
}
424461

425462
// IsFlapping returns the current flapping state.

0 commit comments

Comments
 (0)