summaryrefslogtreecommitdiff
path: root/internal/notify_state.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/notify_state.go')
-rw-r--r--internal/notify_state.go114
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)
+}