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 @@

Recent history

No results yet. The first poll should appear shortly.
{{end}} + +
+
+
+

Webhook alert

+
Fires a POST request when this monitor changes status (up ↔ down).
+
+
+
+ + + {{if .Check.WebhookURL}} + + {{end}} +
+
From 8eee1b56eb0ee1fc38a6e649b8b2af85d4ff0ffa Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 14:09:47 +0100 Subject: [PATCH 4/6] Add tests for webhook store & dashboard handler --- internal/httpapi/server_test.go | 59 +++++++++++++++++++++++++++++++++ internal/store/store_test.go | 42 +++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 2f99c98..1216fb7 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -335,3 +335,62 @@ type stubTriggerer struct { func (s *stubTriggerer) RunNow(_ string) (model.Result, error) { return s.result, s.err } + +func TestSetWebhookViaDashboard(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + created, err := dataStore.CreateCheck(model.Check{ + Name: "Webhook Form Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + + form := url.Values{"webhook_url": []string{"https://hooks.example.com/notify"}} + req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/webhook", bytes.NewBufferString(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusSeeOther { + t.Fatalf("set webhook status = %d, want %d", res.Code, http.StatusSeeOther) + } + + check, err := dataStore.GetCheck(created.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if check.WebhookURL != "https://hooks.example.com/notify" { + t.Fatalf("WebhookURL = %q, want set value", check.WebhookURL) + } + + clearForm := url.Values{"webhook_url": []string{""}} + clearReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/webhook", bytes.NewBufferString(clearForm.Encode())) + clearReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + clearRes := httptest.NewRecorder() + server.Handler().ServeHTTP(clearRes, clearReq) + + if clearRes.Code != http.StatusSeeOther { + t.Fatalf("clear webhook status = %d, want %d", clearRes.Code, http.StatusSeeOther) + } + + check, err = dataStore.GetCheck(created.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if check.WebhookURL != "" { + t.Fatalf("WebhookURL = %q after clear, want empty", check.WebhookURL) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0111209..7d44094 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -41,3 +41,45 @@ func TestFileStorePersistsChecks(t *testing.T) { t.Fatalf("GetCheck().Name = %q, want %q", got.Name, check.Name) } } + +func TestSetCheckWebhook(t *testing.T) { + t.Parallel() + + fs, err := NewFileStore(filepath.Join(t.TempDir(), "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + check, err := fs.CreateCheck(model.Check{ + Name: "Webhook Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 10, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + updated, err := fs.SetCheckWebhook(check.ID, "https://hooks.example.com/notify") + if err != nil { + t.Fatalf("SetCheckWebhook() error = %v", err) + } + if updated.WebhookURL != "https://hooks.example.com/notify" { + t.Fatalf("WebhookURL = %q, want set value", updated.WebhookURL) + } + + // clear it + cleared, err := fs.SetCheckWebhook(check.ID, "") + if err != nil { + t.Fatalf("SetCheckWebhook(clear) error = %v", err) + } + if cleared.WebhookURL != "" { + t.Fatalf("WebhookURL = %q after clear, want empty", cleared.WebhookURL) + } + + _, err = fs.SetCheckWebhook("nonexistent", "https://example.com") + if err == nil { + t.Fatal("SetCheckWebhook() expected error for unknown ID, got nil") + } +} From 5b3ac46838c242cb8739fc4f4949f3c4548d47b8 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 14:21:14 +0100 Subject: [PATCH 5/6] Create CHANGELOG.md Added releases to-date --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c3a8a96 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.1.0] + +### Added +- "Check now" button on each monitor's detail page — runs the check + immediately and refreshes the result history without waiting for the next + polling interval. +- `POST /checks/{id}/trigger` API endpoint that returns the result as JSON, + for scripting and integrations. + +### Changed +- CI workflow updated to use Node.js 24. + +## [1.0.0] + +### Added +- First public release of pingtower — a lightweight self-hosted uptime + monitor for websites and APIs. +- Built-in web dashboard for viewing monitors, adding new ones, and + inspecting per-monitor history. +- Per-check polling intervals and request timeouts. +- Status history with pause, resume, and delete controls per monitor. +- Configurable expected status code per monitor. +- Local JSON-backed storage (no database required). +- Docker support via `docker compose up --build`. +- Unit tests and GitHub Actions CI. + +[1.1.0]: https://github.com/crleonard/pingtower/releases/tag/v1.1.0 +[1.0.0]: https://github.com/crleonard/pingtower/releases/tag/v1.0.0 From c8a127b2c018e5a1d89fc0de2451406158d8dee3 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 17:17:05 +0100 Subject: [PATCH 6/6] Update CHANGELOG.md --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a8a96..a31775a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [1.2.0] + +### Added +- Webhook alerts on status transitions. Configure a webhook URL per monitor + from its detail page. Pingtower POSTs a JSON payload whenever a check goes + from up to down or back again. +- New `webhook_url` field on the check model and `SetCheckWebhook` store + method. +- Dashboard route `POST /dashboard/checks/{id}/webhook` to set or clear the + webhook URL. + +### Notes +- Webhooks fire only on transitions never on the very first poll, so adding + a monitor doesn't trigger a false alert. +- Delivery is fire-and-forget with a 10-second timeout. Failures are logged + but never block the polling loop. +- Payload includes `check_id`, `name`, `url`, `status`, `previous_status`, + `status_code`, `response_ms`, and `checked_at`. +- Works with anything that accepts a JSON POST. Use [webhook.site](https://webhook.site) + to inspect payloads while testing. + ## [1.1.0] ### Added @@ -28,5 +49,6 @@ All notable changes to this project will be documented in this file. - Docker support via `docker compose up --build`. - Unit tests and GitHub Actions CI. +[1.2.0]: https://github.com/crleonard/pingtower/releases/tag/v1.2.0 [1.1.0]: https://github.com/crleonard/pingtower/releases/tag/v1.1.0 [1.0.0]: https://github.com/crleonard/pingtower/releases/tag/v1.0.0