From cf58bcae4ec84250716673f05ccd3caa511864d2 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 10:29:44 +0100 Subject: [PATCH 1/6] add webhook URL field to check model and store --- internal/model/model.go | 1 + internal/store/store.go | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/internal/model/model.go b/internal/model/model.go index 5add349..a6681d6 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -10,6 +10,7 @@ type Check struct { TimeoutSeconds int `json:"timeout_seconds"` ExpectedStatusCode int `json:"expected_status_code"` Paused bool `json:"paused"` + WebhookURL string `json:"webhook_url,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastCheckedAt time.Time `json:"last_checked_at,omitempty"` diff --git a/internal/store/store.go b/internal/store/store.go index 126f121..f6418b0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -23,6 +23,7 @@ type Store interface { ListResults(id string) ([]model.Result, error) UpdateCheckStatus(id string, result model.Result, maxHistory int) error SetCheckPaused(id string, paused bool) (model.Check, error) + SetCheckWebhook(id string, webhookURL string) (model.Check, error) DeleteCheck(id string) error } @@ -153,6 +154,25 @@ func (fs *FileStore) SetCheckPaused(id string, paused bool) (model.Check, error) return check, nil } +func (fs *FileStore) SetCheckWebhook(id string, webhookURL string) (model.Check, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + check, ok := fs.checks[id] + if !ok { + return model.Check{}, ErrNotFound + } + + check.WebhookURL = webhookURL + check.UpdatedAt = time.Now().UTC() + fs.checks[id] = check + + if err := fs.saveLocked(); err != nil { + return model.Check{}, err + } + return check, nil +} + func (fs *FileStore) DeleteCheck(id string) error { fs.mu.Lock() defer fs.mu.Unlock() From a7fd5d0c2032c4996c6a0d6f07072cdc46acd7cb Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 11:19:09 +0100 Subject: [PATCH 2/6] fire webhook on monitor status change --- internal/monitor/service.go | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/monitor/service.go b/internal/monitor/service.go index 6ea3c06..6f31027 100644 --- a/internal/monitor/service.go +++ b/internal/monitor/service.go @@ -1,7 +1,9 @@ package monitor import ( + "bytes" "context" + "encoding/json" "io" "log" "net/http" @@ -156,4 +158,45 @@ func (s *Service) persistResult(check model.Check, result model.Result) { result.StatusCode, result.ResponseMS, ) + + if check.WebhookURL != "" && check.LastStatus != "" && result.Status != check.LastStatus { + go s.fireWebhook(check, result) + } +} + +func (s *Service) fireWebhook(check model.Check, result model.Result) { + payload, err := json.Marshal(map[string]any{ + "check_id": check.ID, + "name": check.Name, + "url": check.URL, + "status": result.Status, + "previous_status": check.LastStatus, + "status_code": result.StatusCode, + "response_ms": result.ResponseMS, + "checked_at": result.CheckedAt, + }) + if err != nil { + s.logger.Printf("webhook marshal failed check_id=%s error=%v", check.ID, err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, check.WebhookURL, bytes.NewReader(payload)) + if err != nil { + s.logger.Printf("webhook request build failed check_id=%s error=%v", check.ID, err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", s.userAgent) + + resp, err := s.httpClient.Do(req) + if err != nil { + s.logger.Printf("webhook delivery failed check_id=%s error=%v", check.ID, err) + return + } + defer resp.Body.Close() + + s.logger.Printf("webhook delivered check_id=%s status=%d", check.ID, resp.StatusCode) } From 49d301fa4f60c614b8ac632622b06cc4ccbb0afc Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 13:03:53 +0100 Subject: [PATCH 3/6] add webhook config form to monitor detail page --- internal/httpapi/dashboard.go | 14 ++++++++++++++ internal/httpapi/templates/detail.html | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/httpapi/dashboard.go b/internal/httpapi/dashboard.go index 6df6cd4..208f19c 100644 --- a/internal/httpapi/dashboard.go +++ b/internal/httpapi/dashboard.go @@ -32,6 +32,7 @@ type dashboardCheckView struct { IntervalSeconds int TimeoutSeconds int Paused bool + WebhookURL string LastStatus string LastStatusCode int LastResponseMS int64 @@ -138,6 +139,18 @@ func (s *Server) handleDashboardCheckAction(w http.ResponseWriter, r *http.Reque var paused bool switch action { + case "webhook": + webhookURL := strings.TrimSpace(r.FormValue("webhook_url")) + if _, err := s.store.SetCheckWebhook(checkID, webhookURL); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update webhook", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) + return case "trigger": if s.triggerer == nil { http.Error(w, "trigger not available", http.StatusNotImplemented) @@ -289,6 +302,7 @@ func newDashboardCheckView(check model.Check) dashboardCheckView { IntervalSeconds: check.IntervalSeconds, TimeoutSeconds: check.TimeoutSeconds, Paused: check.Paused, + WebhookURL: check.WebhookURL, LastStatus: effectiveStatus(check), LastStatusCode: check.LastStatusCode, LastResponseMS: check.LastResponseMS, diff --git a/internal/httpapi/templates/detail.html b/internal/httpapi/templates/detail.html index 36b3212..ecc8d4d 100644 --- a/internal/httpapi/templates/detail.html +++ b/internal/httpapi/templates/detail.html @@ -267,6 +267,28 @@