From 2ba197b56df2c39547de4680a74e61ffb981d3c3 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Sun, 3 May 2026 14:28:59 +0100 Subject: [PATCH 1/7] 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 From 388acf108a2c15140a0343ad10b65efe7bdc306d Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Tue, 5 May 2026 06:55:33 +0100 Subject: [PATCH 2/7] add headers and auth fields to check model and store --- internal/model/model.go | 33 +++++++++++++------------ internal/store/store.go | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/internal/model/model.go b/internal/model/model.go index a6681d6..867cf95 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -3,21 +3,24 @@ package model import "time" type Check struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - IntervalSeconds int `json:"interval_seconds"` - 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"` - LastStatus string `json:"last_status,omitempty"` - LastStatusCode int `json:"last_status_code,omitempty"` - LastResponseMS int64 `json:"last_response_ms,omitempty"` - LastError string `json:"last_error,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + IntervalSeconds int `json:"interval_seconds"` + TimeoutSeconds int `json:"timeout_seconds"` + ExpectedStatusCode int `json:"expected_status_code"` + Paused bool `json:"paused"` + WebhookURL string `json:"webhook_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + AuthType string `json:"auth_type,omitempty"` + AuthValue string `json:"auth_value,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastCheckedAt time.Time `json:"last_checked_at,omitempty"` + LastStatus string `json:"last_status,omitempty"` + LastStatusCode int `json:"last_status_code,omitempty"` + LastResponseMS int64 `json:"last_response_ms,omitempty"` + LastError string `json:"last_error,omitempty"` } type Result struct { diff --git a/internal/store/store.go b/internal/store/store.go index f6418b0..8dc9bf9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,6 +24,8 @@ type Store interface { 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) + SetCheckHeaders(id string, headers map[string]string) (model.Check, error) + SetCheckAuth(id string, authType string, authValue string) (model.Check, error) DeleteCheck(id string) error } @@ -173,6 +175,58 @@ func (fs *FileStore) SetCheckWebhook(id string, webhookURL string) (model.Check, return check, nil } +func (fs *FileStore) SetCheckHeaders(id string, headers map[string]string) (model.Check, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + check, ok := fs.checks[id] + if !ok { + return model.Check{}, ErrNotFound + } + + if len(headers) == 0 { + check.Headers = nil + } else { + copied := make(map[string]string, len(headers)) + for k, v := range headers { + copied[k] = v + } + check.Headers = copied + } + 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) SetCheckAuth(id string, authType string, authValue string) (model.Check, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + check, ok := fs.checks[id] + if !ok { + return model.Check{}, ErrNotFound + } + + if authType == "" || authType == "none" { + check.AuthType = "" + check.AuthValue = "" + } else { + check.AuthType = authType + check.AuthValue = authValue + } + 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 adf566eb5cb9986e18b5dc4e025695deba8059f9 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Tue, 5 May 2026 07:09:04 +0100 Subject: [PATCH 3/7] apply headers and auth to outbound poll requests --- internal/monitor/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/monitor/service.go b/internal/monitor/service.go index 6f31027..14cef55 100644 --- a/internal/monitor/service.go +++ b/internal/monitor/service.go @@ -3,6 +3,7 @@ package monitor import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "log" @@ -110,6 +111,13 @@ func (s *Service) evaluate(check model.Check) model.Result { return result } req.Header.Set("User-Agent", s.userAgent) + applyAuth(req, check) + for key, value := range check.Headers { + if strings.TrimSpace(key) == "" { + continue + } + req.Header.Set(key, value) + } resp, err := s.httpClient.Do(req) if err != nil { @@ -200,3 +208,15 @@ func (s *Service) fireWebhook(check model.Check, result model.Result) { s.logger.Printf("webhook delivered check_id=%s status=%d", check.ID, resp.StatusCode) } + +func applyAuth(req *http.Request, check model.Check) { + if check.AuthValue == "" { + return + } + switch check.AuthType { + case "bearer": + req.Header.Set("Authorization", "Bearer "+check.AuthValue) + case "basic": + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(check.AuthValue))) + } +} From 2962e61a298aac1c60cee68345ee89f624ea6c64 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Tue, 5 May 2026 20:19:24 +0100 Subject: [PATCH 4/7] add headers and auth forms to monitor detail page --- internal/httpapi/dashboard.go | 98 ++++++++++++++++++++++++++ internal/httpapi/templates/detail.html | 51 ++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/internal/httpapi/dashboard.go b/internal/httpapi/dashboard.go index 208f19c..21a50af 100644 --- a/internal/httpapi/dashboard.go +++ b/internal/httpapi/dashboard.go @@ -6,6 +6,7 @@ import ( "fmt" "html/template" "net/http" + "sort" "strconv" "strings" "time" @@ -33,6 +34,10 @@ type dashboardCheckView struct { TimeoutSeconds int Paused bool WebhookURL string + Headers map[string]string + HeadersText string + AuthType string + HasAuth bool LastStatus string LastStatusCode int LastResponseMS int64 @@ -151,6 +156,50 @@ func (s *Server) handleDashboardCheckAction(w http.ResponseWriter, r *http.Reque } http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) return + case "headers": + headers := parseHeadersText(r.FormValue("headers")) + if _, err := s.store.SetCheckHeaders(checkID, headers); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update headers", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) + return + case "auth": + authType := strings.TrimSpace(r.FormValue("auth_type")) + authValue := r.FormValue("auth_value") + if authType != "" && authType != "none" && authType != "basic" && authType != "bearer" { + http.Error(w, "invalid auth_type", http.StatusBadRequest) + return + } + if authType == "none" || authType == "" { + authValue = "" + } else if strings.TrimSpace(authValue) == "" { + // keep the existing secret if the user didn't supply a new one + existing, err := s.store.GetCheck(checkID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to load check", http.StatusInternalServerError) + return + } + authValue = existing.AuthValue + } + if _, err := s.store.SetCheckAuth(checkID, authType, authValue); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update auth", 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) @@ -303,6 +352,10 @@ func newDashboardCheckView(check model.Check) dashboardCheckView { TimeoutSeconds: check.TimeoutSeconds, Paused: check.Paused, WebhookURL: check.WebhookURL, + Headers: check.Headers, + HeadersText: headersToText(check.Headers), + AuthType: check.AuthType, + HasAuth: check.AuthValue != "", LastStatus: effectiveStatus(check), LastStatusCode: check.LastStatusCode, LastResponseMS: check.LastResponseMS, @@ -375,6 +428,51 @@ func withFormDefaults(values map[string]string, s *Server) map[string]string { return values } +func parseHeadersText(text string) map[string]string { + headers := map[string]string{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key == "" { + continue + } + headers[key] = value + } + if len(headers) == 0 { + return nil + } + return headers +} + +func headersToText(headers map[string]string) string { + if len(headers) == 0 { + return "" + } + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(headers[k]) + } + return b.String() +} + func parseIntOrZero(value string) int { if strings.TrimSpace(value) == "" { return 0 diff --git a/internal/httpapi/templates/detail.html b/internal/httpapi/templates/detail.html index ecc8d4d..b5e3bb0 100644 --- a/internal/httpapi/templates/detail.html +++ b/internal/httpapi/templates/detail.html @@ -289,6 +289,57 @@