Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions internal/httpapi/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type dashboardCheckView struct {
IntervalSeconds int
TimeoutSeconds int
Paused bool
WebhookURL string
LastStatus string
LastStatusCode int
LastResponseMS int64
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
22 changes: 22 additions & 0 deletions internal/httpapi/templates/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,28 @@ <h2>Recent history</h2>
<div class="row-meta">No results yet. The first poll should appear shortly.</div>
{{end}}
</section>

<section class="panel history" style="margin-top:22px;">
<div class="topline" style="margin-bottom:0;">
<div>
<h2>Webhook alert</h2>
<div class="row-meta">Fires a POST request when this monitor changes status (up ↔ down).</div>
</div>
</div>
<form method="post" action="/dashboard/checks/{{.Check.ID}}/webhook" style="margin-top:20px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<input
type="url"
name="webhook_url"
value="{{.Check.WebhookURL}}"
placeholder="https://hooks.slack.com/services/…"
style="flex:1;min-width:260px;padding:11px 14px;border-radius:999px;border:1px solid var(--line);font:inherit;background:rgba(255,255,255,0.8);"
>
<button type="submit">{{if .Check.WebhookURL}}Update{{else}}Save{{end}}</button>
{{if .Check.WebhookURL}}
<button type="submit" name="webhook_url" value="" class="button-danger" onclick="this.form.querySelector('[name=webhook_url]').value=''">Remove</button>
{{end}}
</form>
</section>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
43 changes: 43 additions & 0 deletions internal/monitor/service.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package monitor

import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
Expand Down Expand Up @@ -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)
}
20 changes: 20 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading