diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a31775a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +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 +- "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.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 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/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/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 @@