diff options
Diffstat (limited to 'internal/notify_state.go')
| -rw-r--r-- | internal/notify_state.go | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/internal/notify_state.go b/internal/notify_state.go new file mode 100644 index 0000000..9c3ccd4 --- /dev/null +++ b/internal/notify_state.go @@ -0,0 +1,114 @@ +package internal + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// notifyState tracks the last notification timestamp and the check states at that time. +// This enables notification batching: Gogios can suppress emails until the configured +// interval has elapsed AND there's been an actual state change since the last notification. +type notifyState struct { + stateFile string `json:"-"` + LastNotifyEpoch int64 `json:"LastNotifyEpoch"` + CheckStates map[string]int `json:"CheckStates"` // check name -> status code at last notification +} + +// newNotifyState loads the notification state from disk, or returns an empty state +// if no previous state exists (first run scenario). +func newNotifyState(stateDir string) (notifyState, error) { + ns := notifyState{ + stateFile: filepath.Join(stateDir, "notify_state.json"), + CheckStates: make(map[string]int), + } + + data, err := os.ReadFile(ns.stateFile) + if err != nil { + if os.IsNotExist(err) { + // First run - no previous notification state + return ns, nil + } + return ns, err + } + + if err := json.Unmarshal(data, &ns); err != nil { + return ns, err + } + + return ns, nil +} + +// intervalElapsed returns true if the minimum notification interval has passed +// since the last notification was sent. +func (ns notifyState) intervalElapsed(minIntervalS int) bool { + if ns.LastNotifyEpoch == 0 { + // No previous notification - interval is considered elapsed + return true + } + elapsed := time.Now().Unix() - ns.LastNotifyEpoch + return elapsed >= int64(minIntervalS) +} + +// hasChanges compares the current check states to the snapshot taken at the last +// notification. Returns true if any check has changed status, if new checks were +// added, or if checks were removed. +func (ns notifyState) hasChanges(currentState state) bool { + // Check for status changes or new checks + for name, cs := range currentState.checks { + prevStatus, exists := ns.CheckStates[name] + if !exists { + // New check appeared since last notification + return true + } + if int(cs.Status) != prevStatus { + // Status changed since last notification + return true + } + } + + // Check for removed checks + for name := range ns.CheckStates { + if _, exists := currentState.checks[name]; !exists { + return true + } + } + + return false +} + +// recordNotification saves the current timestamp and a snapshot of all check states. +// Call this after successfully sending a notification email. +func (ns *notifyState) recordNotification(currentState state) error { + ns.LastNotifyEpoch = time.Now().Unix() + ns.CheckStates = make(map[string]int) + + for name, cs := range currentState.checks { + ns.CheckStates[name] = int(cs.Status) + } + + return ns.persist() +} + +// persist writes the notification state to disk atomically using a temp file. +func (ns notifyState) persist() error { + stateDir := filepath.Dir(ns.stateFile) + if _, err := os.Stat(stateDir); os.IsNotExist(err) { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + } + + data, err := json.Marshal(ns) + if err != nil { + return err + } + + tmpFile := ns.stateFile + ".tmp" + if err := os.WriteFile(tmpFile, data, 0o644); err != nil { + return err + } + + return os.Rename(tmpFile, ns.stateFile) +} |
