diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9cf458..c2e185b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: workflow_dispatch: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: unit-tests: name: Unit Tests @@ -11,10 +14,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod cache: true 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 @@

Recent history

Server time: {{.CurrentTime}} • Refreshes every 10 seconds
+
+ + +
{{if .Check.Paused}}
diff --git a/internal/monitor/service.go b/internal/monitor/service.go index 5532dae..6ea3c06 100644 --- a/internal/monitor/service.go +++ b/internal/monitor/service.go @@ -78,7 +78,18 @@ func (s *Service) tick() { } } -func (s *Service) evaluate(check model.Check) { +// RunNow immediately evaluates a check by ID and returns the result. +// Returns an error only for store failures (e.g. store.ErrNotFound); a +// network failure to the monitored URL is captured as a "down" Result. +func (s *Service) RunNow(checkID string) (model.Result, error) { + check, err := s.store.GetCheck(checkID) + if err != nil { + return model.Result{}, err + } + return s.evaluate(check), nil +} + +func (s *Service) evaluate(check model.Check) model.Result { timeout := time.Duration(check.TimeoutSeconds) * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -86,27 +97,29 @@ func (s *Service) evaluate(check model.Check) { start := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, check.URL, nil) if err != nil { - s.persistResult(check, model.Result{ + result := model.Result{ CheckID: check.ID, CheckedAt: time.Now().UTC(), Status: "down", ResponseMS: time.Since(start).Milliseconds(), ErrorMessage: err.Error(), - }) - return + } + s.persistResult(check, result) + return result } req.Header.Set("User-Agent", s.userAgent) resp, err := s.httpClient.Do(req) if err != nil { - s.persistResult(check, model.Result{ + result := model.Result{ CheckID: check.ID, CheckedAt: time.Now().UTC(), Status: "down", ResponseMS: time.Since(start).Milliseconds(), ErrorMessage: err.Error(), - }) - return + } + s.persistResult(check, result) + return result } defer resp.Body.Close() @@ -116,14 +129,16 @@ func (s *Service) evaluate(check model.Check) { status = "down" } - s.persistResult(check, model.Result{ + result := model.Result{ CheckID: check.ID, CheckedAt: time.Now().UTC(), Status: status, StatusCode: resp.StatusCode, ResponseMS: time.Since(start).Milliseconds(), ResponseSample: strings.TrimSpace(string(body)), - }) + } + s.persistResult(check, result) + return result } func (s *Service) persistResult(check model.Check, result model.Result) { diff --git a/internal/monitor/service_test.go b/internal/monitor/service_test.go new file mode 100644 index 0000000..d40825c --- /dev/null +++ b/internal/monitor/service_test.go @@ -0,0 +1,105 @@ +package monitor + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/crleonard/pingtower/internal/model" + "github.com/crleonard/pingtower/internal/store" +) + +func newTestService(t *testing.T) (*Service, store.Store) { + t.Helper() + dataStore, err := store.NewFileStore(filepath.Join(t.TempDir(), "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + return NewService(dataStore, log.New(io.Discard, "", 0), "pingtower-test", 10), dataStore +} + +func TestRunNow_Healthy(t *testing.T) { + t.Parallel() + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + svc, dataStore := newTestService(t) + + check, err := dataStore.CreateCheck(model.Check{ + Name: "RunNow healthy", + URL: target.URL, + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: http.StatusOK, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + result, err := svc.RunNow(check.ID) + if err != nil { + t.Fatalf("RunNow() error = %v", err) + } + if result.Status != "healthy" { + t.Fatalf("Status = %q, want %q", result.Status, "healthy") + } + if result.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want %d", result.StatusCode, http.StatusOK) + } + + // result should also be persisted in the store + stored, err := dataStore.GetCheck(check.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if stored.LastStatus != "healthy" { + t.Fatalf("LastStatus = %q, want %q", stored.LastStatus, "healthy") + } +} + +func TestRunNow_Down(t *testing.T) { + t.Parallel() + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer target.Close() + + svc, dataStore := newTestService(t) + + check, err := dataStore.CreateCheck(model.Check{ + Name: "RunNow down", + URL: target.URL, + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: http.StatusOK, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + result, err := svc.RunNow(check.ID) + if err != nil { + t.Fatalf("RunNow() error = %v", err) + } + if result.Status != "down" { + t.Fatalf("Status = %q, want %q", result.Status, "down") + } +} + +func TestRunNow_NotFound(t *testing.T) { + t.Parallel() + + svc, _ := newTestService(t) + + _, err := svc.RunNow("nonexistent-id") + if err == nil { + t.Fatal("RunNow() expected error for unknown ID, got nil") + } +}