From 26f22f98c30df22fdd4ba32778195d98e0d7b760 Mon Sep 17 00:00:00 2001 From: Chris Leonard <35844395+crleonard@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:46:12 +0100 Subject: [PATCH 1/3] dd on-demand Check Now for monitors (v1.1) Adds POST /checks/{id}/trigger API endpoint and a "Check now" button --- cmd/server/main.go | 1 + internal/httpapi/dashboard.go | 19 ++++ internal/httpapi/server.go | 52 +++++++++- internal/httpapi/server_test.go | 134 +++++++++++++++++++++++++ internal/httpapi/templates/detail.html | 4 + internal/monitor/service.go | 33 ++++-- internal/monitor/service_test.go | 105 +++++++++++++++++++ 7 files changed, 335 insertions(+), 13 deletions(-) create mode 100644 internal/monitor/service_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 71635ca..7282174 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -32,6 +32,7 @@ func main() { defer service.Stop() server := httpapi.NewServer(cfg, logger, dataStore) + server.SetTriggerer(service) httpServer := &http.Server{ Addr: cfg.ListenAddr, diff --git a/internal/httpapi/dashboard.go b/internal/httpapi/dashboard.go index ef87a79..6df6cd4 100644 --- a/internal/httpapi/dashboard.go +++ b/internal/httpapi/dashboard.go @@ -138,6 +138,25 @@ func (s *Server) handleDashboardCheckAction(w http.ResponseWriter, r *http.Reque var paused bool switch action { + case "trigger": + if s.triggerer == nil { + http.Error(w, "trigger not available", http.StatusNotImplemented) + return + } + if _, err := s.triggerer.RunNow(checkID); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to trigger check", http.StatusInternalServerError) + return + } + redirectTo := strings.TrimSpace(r.FormValue("redirect_to")) + if redirectTo == "" { + redirectTo = "/" + } + http.Redirect(w, r, redirectTo, http.StatusSeeOther) + return case "pause": paused = true case "resume": diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7e1686c..7080125 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -9,14 +9,26 @@ import ( "strings" "github.com/crleonard/pingtower/internal/config" + "github.com/crleonard/pingtower/internal/model" "github.com/crleonard/pingtower/internal/store" ) +// Triggerer runs an on-demand check evaluation. +type Triggerer interface { + RunNow(checkID string) (model.Result, error) +} + type Server struct { - cfg config.Config - logger *log.Logger - store store.Store - mux *http.ServeMux + cfg config.Config + logger *log.Logger + store store.Store + triggerer Triggerer + mux *http.ServeMux +} + +// SetTriggerer wires the monitor service so the trigger endpoint works. +func (s *Server) SetTriggerer(t Triggerer) { + s.triggerer = t } type createCheckRequest struct { @@ -48,6 +60,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /dashboard/checks", s.handleCreateCheckForm) s.mux.HandleFunc("POST /dashboard/checks/", s.handleDashboardCheckAction) s.mux.HandleFunc("GET /checks/", s.handleCheckSubresource) + s.mux.HandleFunc("POST /checks/", s.handleCheckPOSTSubresource) s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /checks", s.handleListChecks) s.mux.HandleFunc("POST /checks", s.handleCreateCheck) @@ -187,6 +200,37 @@ func (s *Server) handleDeleteCheck(w http.ResponseWriter, _ *http.Request, check w.WriteHeader(http.StatusNoContent) } +func (s *Server) handleCheckPOSTSubresource(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/checks/") + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 2 || parts[0] == "" { + http.NotFound(w, r) + return + } + if parts[1] == "trigger" { + s.handleTriggerCheck(w, r, parts[0]) + return + } + http.NotFound(w, r) +} + +func (s *Server) handleTriggerCheck(w http.ResponseWriter, _ *http.Request, checkID string) { + if s.triggerer == nil { + writeError(w, http.StatusNotImplemented, "trigger not available") + return + } + result, err := s.triggerer.RunNow(checkID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "check not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to trigger check") + return + } + writeJSON(w, http.StatusOK, result) +} + func (s *Server) loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index f32c5a9..2f99c98 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -201,3 +201,137 @@ func TestDeleteCheckViaDashboard(t *testing.T) { t.Fatalf("GetCheck() error = %v, want ErrNotFound", err) } } + +func TestTriggerCheckAPI(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: "Trigger 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) + server.SetTriggerer(&stubTriggerer{result: model.Result{ + CheckID: created.ID, + Status: "healthy", + }}) + + req := httptest.NewRequest(http.MethodPost, "/checks/"+created.ID+"/trigger", nil) + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("POST /checks/{id}/trigger status = %d, want %d", res.Code, http.StatusOK) + } + + var result model.Result + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Status != "healthy" { + t.Fatalf("result.Status = %q, want %q", result.Status, "healthy") + } +} + +func TestTriggerCheckAPI_NotFound(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) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + server.SetTriggerer(&stubTriggerer{err: store.ErrNotFound}) + + req := httptest.NewRequest(http.MethodPost, "/checks/nonexistent/trigger", nil) + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", res.Code, http.StatusNotFound) + } +} + +func TestTriggerCheckAPI_NoTriggerer(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) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + + req := httptest.NewRequest(http.MethodPost, "/checks/anything/trigger", nil) + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusNotImplemented { + t.Fatalf("status = %d, want %d", res.Code, http.StatusNotImplemented) + } +} + +func TestTriggerCheckDashboard(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: "Dashboard Trigger 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) + server.SetTriggerer(&stubTriggerer{result: model.Result{ + CheckID: created.ID, + Status: "healthy", + }}) + + form := url.Values{"redirect_to": []string{"/checks/" + created.ID + "/view"}} + req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/trigger", 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("dashboard trigger status = %d, want %d", res.Code, http.StatusSeeOther) + } + if loc := res.Header().Get("Location"); loc != "/checks/"+created.ID+"/view" { + t.Fatalf("Location = %q, want detail page", loc) + } +} + +// stubTriggerer is a test double for the Triggerer interface. +type stubTriggerer struct { + result model.Result + err error +} + +func (s *stubTriggerer) RunNow(_ string) (model.Result, error) { + return s.result, s.err +} diff --git a/internal/httpapi/templates/detail.html b/internal/httpapi/templates/detail.html index eeee88d..36b3212 100644 --- a/internal/httpapi/templates/detail.html +++ b/internal/httpapi/templates/detail.html @@ -224,6 +224,10 @@