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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ on:
push:
workflow_dispatch:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest

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
Expand Down
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func main() {
defer service.Stop()

server := httpapi.NewServer(cfg, logger, dataStore)
server.SetTriggerer(service)

httpServer := &http.Server{
Addr: cfg.ListenAddr,
Expand Down
19 changes: 19 additions & 0 deletions internal/httpapi/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
52 changes: 48 additions & 4 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
134 changes: 134 additions & 0 deletions internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions internal/httpapi/templates/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ <h2>Recent history</h2>
<div class="row-meta">Server time: {{.CurrentTime}} • Refreshes every 10 seconds</div>
</div>
<div class="actions">
<form method="post" action="/dashboard/checks/{{.Check.ID}}/trigger">
<input type="hidden" name="redirect_to" value="/checks/{{.Check.ID}}/view">
<button type="submit">Check now</button>
</form>
{{if .Check.Paused}}
<form method="post" action="/dashboard/checks/{{.Check.ID}}/resume">
<input type="hidden" name="redirect_to" value="/checks/{{.Check.ID}}/view">
Expand Down
33 changes: 24 additions & 9 deletions internal/monitor/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,35 +78,48 @@ 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()

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()

Expand All @@ -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) {
Expand Down
Loading
Loading