From 25f4b20b87622307bd87b846945a735a725e52b6 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 28 Jun 2026 11:00:25 -0400 Subject: [PATCH] feat(monitor): save state changes for group monitors checkGroup() now detects status transitions and persists them as state change records, same as individual monitors. Groups also get StatusChangedAt updates and log entries for down/recovery events. This enables history and SLA views for group monitors. --- internal/monitor/checks.go | 14 ++++ internal/monitor/monitor_test.go | 130 +++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/internal/monitor/checks.go b/internal/monitor/checks.go index daf7341..43588f7 100644 --- a/internal/monitor/checks.go +++ b/internal/monitor/checks.go @@ -239,10 +239,24 @@ func (e *Engine) checkGroup(_ context.Context, site models.Site) { status = models.StatusPending } + var prev models.Status e.applyState(site.ID, func(s *models.Site) { + prev = s.Status s.Status = status + if status != prev && prev != models.StatusPending { + s.StatusChangedAt = time.Now() + } }) e.recordCheck(site.ID, 0, !status.IsBroken()) + + if status != prev && prev != models.StatusPending { + e.enqueueWrite(writeStateChange{siteID: site.ID, fromStatus: string(prev), toStatus: string(status)}) + if status.IsBroken() { + e.AddLog(fmt.Sprintf("Group '%s' is %s", site.Name, status)) + } else if prev.IsBroken() { + e.AddLog(fmt.Sprintf("Group '%s' recovered", site.Name)) + } + } } func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) { diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index daa351c..1ef7f93 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -445,6 +445,136 @@ func TestEngineStop_Idempotent(t *testing.T) { e.Stop() // must not panic or block } +// --- Group 11: Group State History --- + +func TestCheckGroup_SavesStateChange(t *testing.T) { + ms := newMockStore() + e := newTestEngine(ms) + + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"}, + SiteState: models.SiteState{Status: models.StatusUp}, + } + child := models.Site{ + SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100}, + SiteState: models.SiteState{Status: models.StatusUp}, + } + injectSite(e, group) + injectSite(e, child) + + // Child goes down → group should transition UP→DOWN. + child.Status = models.StatusDown + injectSite(e, child) + e.checkGroup(context.Background(), group) + + s, ok := getSite(e, 100) + if !ok { + t.Fatal("group not found") + } + if s.Status != models.StatusDown { + t.Errorf("expected DOWN, got %s", s.Status) + } + if s.StatusChangedAt.IsZero() { + t.Error("expected StatusChangedAt to be set") + } + + logs := e.GetLogs() + found := false + for _, l := range logs { + if containsStr(l.Message, "Group 'Infra' is DOWN") { + found = true + break + } + } + if !found { + t.Error("expected log entry for group state change") + } + + // Drain the write channel to verify a state change was enqueued. + select { + case w := <-e.dbWrites: + if w.desc() != "state-change" { + // Could be a check write; try one more. + select { + case w2 := <-e.dbWrites: + if w2.desc() != "state-change" { + t.Errorf("expected state-change write, got %s then %s", w.desc(), w2.desc()) + } + default: + t.Errorf("expected state-change write, got only %s", w.desc()) + } + } + default: + t.Error("no write enqueued for group state change") + } +} + +func TestCheckGroup_NoChangeNoWrite(t *testing.T) { + ms := newMockStore() + e := newTestEngine(ms) + + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"}, + SiteState: models.SiteState{Status: models.StatusUp}, + } + child := models.Site{ + SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100}, + SiteState: models.SiteState{Status: models.StatusUp}, + } + injectSite(e, group) + injectSite(e, child) + + e.checkGroup(context.Background(), group) + + // Only a check write should be enqueued, no state-change. + drainCount := 0 + for { + select { + case w := <-e.dbWrites: + if w.desc() == "state-change" { + t.Error("state-change write enqueued when status didn't change") + } + drainCount++ + default: + goto done + } + } +done: + if drainCount == 0 { + t.Error("expected at least a check write") + } +} + +func TestCheckGroup_RecoveryLog(t *testing.T) { + ms := newMockStore() + e := newTestEngine(ms) + + group := models.Site{ + SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"}, + SiteState: models.SiteState{Status: models.StatusDown}, + } + child := models.Site{ + SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100}, + SiteState: models.SiteState{Status: models.StatusUp}, + } + injectSite(e, group) + injectSite(e, child) + + e.checkGroup(context.Background(), group) + + logs := e.GetLogs() + found := false + for _, l := range logs { + if containsStr(l.Message, "Group 'Infra' recovered") { + found = true + break + } + } + if !found { + t.Error("expected recovery log entry for group") + } +} + // --- Utilities --- func containsStr(s, substr string) bool {