Skip to content

Commit 2f092e5

Browse files
authored
Merge pull request #3 from crleonard/feat/check-now-trigger
add on-demand Check Now for monitors (v1.1)
2 parents e2a444e + a8bc0ad commit 2f092e5

8 files changed

Lines changed: 340 additions & 15 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ on:
44
push:
55
workflow_dispatch:
66

7+
env:
8+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
9+
710
jobs:
811
unit-tests:
912
name: Unit Tests
1013
runs-on: ubuntu-latest
1114

1215
steps:
1316
- name: Check out repository
14-
uses: actions/checkout@v4
17+
uses: actions/checkout@v4.2.2
1518

1619
- name: Set up Go
17-
uses: actions/setup-go@v5
20+
uses: actions/setup-go@v5.5.0
1821
with:
1922
go-version-file: go.mod
2023
cache: true

cmd/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func main() {
3232
defer service.Stop()
3333

3434
server := httpapi.NewServer(cfg, logger, dataStore)
35+
server.SetTriggerer(service)
3536

3637
httpServer := &http.Server{
3738
Addr: cfg.ListenAddr,

internal/httpapi/dashboard.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ func (s *Server) handleDashboardCheckAction(w http.ResponseWriter, r *http.Reque
138138

139139
var paused bool
140140
switch action {
141+
case "trigger":
142+
if s.triggerer == nil {
143+
http.Error(w, "trigger not available", http.StatusNotImplemented)
144+
return
145+
}
146+
if _, err := s.triggerer.RunNow(checkID); err != nil {
147+
if errors.Is(err, store.ErrNotFound) {
148+
http.NotFound(w, r)
149+
return
150+
}
151+
http.Error(w, "failed to trigger check", http.StatusInternalServerError)
152+
return
153+
}
154+
redirectTo := strings.TrimSpace(r.FormValue("redirect_to"))
155+
if redirectTo == "" {
156+
redirectTo = "/"
157+
}
158+
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
159+
return
141160
case "pause":
142161
paused = true
143162
case "resume":

internal/httpapi/server.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,26 @@ import (
99
"strings"
1010

1111
"github.com/crleonard/pingtower/internal/config"
12+
"github.com/crleonard/pingtower/internal/model"
1213
"github.com/crleonard/pingtower/internal/store"
1314
)
1415

16+
// Triggerer runs an on-demand check evaluation.
17+
type Triggerer interface {
18+
RunNow(checkID string) (model.Result, error)
19+
}
20+
1521
type Server struct {
16-
cfg config.Config
17-
logger *log.Logger
18-
store store.Store
19-
mux *http.ServeMux
22+
cfg config.Config
23+
logger *log.Logger
24+
store store.Store
25+
triggerer Triggerer
26+
mux *http.ServeMux
27+
}
28+
29+
// SetTriggerer wires the monitor service so the trigger endpoint works.
30+
func (s *Server) SetTriggerer(t Triggerer) {
31+
s.triggerer = t
2032
}
2133

2234
type createCheckRequest struct {
@@ -48,6 +60,7 @@ func (s *Server) routes() {
4860
s.mux.HandleFunc("POST /dashboard/checks", s.handleCreateCheckForm)
4961
s.mux.HandleFunc("POST /dashboard/checks/", s.handleDashboardCheckAction)
5062
s.mux.HandleFunc("GET /checks/", s.handleCheckSubresource)
63+
s.mux.HandleFunc("POST /checks/", s.handleCheckPOSTSubresource)
5164
s.mux.HandleFunc("GET /health", s.handleHealth)
5265
s.mux.HandleFunc("GET /checks", s.handleListChecks)
5366
s.mux.HandleFunc("POST /checks", s.handleCreateCheck)
@@ -187,6 +200,37 @@ func (s *Server) handleDeleteCheck(w http.ResponseWriter, _ *http.Request, check
187200
w.WriteHeader(http.StatusNoContent)
188201
}
189202

203+
func (s *Server) handleCheckPOSTSubresource(w http.ResponseWriter, r *http.Request) {
204+
path := strings.TrimPrefix(r.URL.Path, "/checks/")
205+
parts := strings.Split(strings.Trim(path, "/"), "/")
206+
if len(parts) != 2 || parts[0] == "" {
207+
http.NotFound(w, r)
208+
return
209+
}
210+
if parts[1] == "trigger" {
211+
s.handleTriggerCheck(w, r, parts[0])
212+
return
213+
}
214+
http.NotFound(w, r)
215+
}
216+
217+
func (s *Server) handleTriggerCheck(w http.ResponseWriter, _ *http.Request, checkID string) {
218+
if s.triggerer == nil {
219+
writeError(w, http.StatusNotImplemented, "trigger not available")
220+
return
221+
}
222+
result, err := s.triggerer.RunNow(checkID)
223+
if err != nil {
224+
if errors.Is(err, store.ErrNotFound) {
225+
writeError(w, http.StatusNotFound, "check not found")
226+
return
227+
}
228+
writeError(w, http.StatusInternalServerError, "failed to trigger check")
229+
return
230+
}
231+
writeJSON(w, http.StatusOK, result)
232+
}
233+
190234
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
191235
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192236
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}

internal/httpapi/server_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,137 @@ func TestDeleteCheckViaDashboard(t *testing.T) {
201201
t.Fatalf("GetCheck() error = %v, want ErrNotFound", err)
202202
}
203203
}
204+
205+
func TestTriggerCheckAPI(t *testing.T) {
206+
t.Parallel()
207+
208+
dir := t.TempDir()
209+
dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json"))
210+
if err != nil {
211+
t.Fatalf("NewFileStore() error = %v", err)
212+
}
213+
214+
created, err := dataStore.CreateCheck(model.Check{
215+
Name: "Trigger Test",
216+
URL: "https://example.com",
217+
IntervalSeconds: 60,
218+
TimeoutSeconds: 5,
219+
ExpectedStatusCode: 200,
220+
})
221+
if err != nil {
222+
t.Fatalf("CreateCheck() error = %v", err)
223+
}
224+
225+
server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore)
226+
server.SetTriggerer(&stubTriggerer{result: model.Result{
227+
CheckID: created.ID,
228+
Status: "healthy",
229+
}})
230+
231+
req := httptest.NewRequest(http.MethodPost, "/checks/"+created.ID+"/trigger", nil)
232+
res := httptest.NewRecorder()
233+
server.Handler().ServeHTTP(res, req)
234+
235+
if res.Code != http.StatusOK {
236+
t.Fatalf("POST /checks/{id}/trigger status = %d, want %d", res.Code, http.StatusOK)
237+
}
238+
239+
var result model.Result
240+
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
241+
t.Fatalf("decode result: %v", err)
242+
}
243+
if result.Status != "healthy" {
244+
t.Fatalf("result.Status = %q, want %q", result.Status, "healthy")
245+
}
246+
}
247+
248+
func TestTriggerCheckAPI_NotFound(t *testing.T) {
249+
t.Parallel()
250+
251+
dir := t.TempDir()
252+
dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json"))
253+
if err != nil {
254+
t.Fatalf("NewFileStore() error = %v", err)
255+
}
256+
257+
server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore)
258+
server.SetTriggerer(&stubTriggerer{err: store.ErrNotFound})
259+
260+
req := httptest.NewRequest(http.MethodPost, "/checks/nonexistent/trigger", nil)
261+
res := httptest.NewRecorder()
262+
server.Handler().ServeHTTP(res, req)
263+
264+
if res.Code != http.StatusNotFound {
265+
t.Fatalf("status = %d, want %d", res.Code, http.StatusNotFound)
266+
}
267+
}
268+
269+
func TestTriggerCheckAPI_NoTriggerer(t *testing.T) {
270+
t.Parallel()
271+
272+
dir := t.TempDir()
273+
dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json"))
274+
if err != nil {
275+
t.Fatalf("NewFileStore() error = %v", err)
276+
}
277+
278+
server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore)
279+
280+
req := httptest.NewRequest(http.MethodPost, "/checks/anything/trigger", nil)
281+
res := httptest.NewRecorder()
282+
server.Handler().ServeHTTP(res, req)
283+
284+
if res.Code != http.StatusNotImplemented {
285+
t.Fatalf("status = %d, want %d", res.Code, http.StatusNotImplemented)
286+
}
287+
}
288+
289+
func TestTriggerCheckDashboard(t *testing.T) {
290+
t.Parallel()
291+
292+
dir := t.TempDir()
293+
dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json"))
294+
if err != nil {
295+
t.Fatalf("NewFileStore() error = %v", err)
296+
}
297+
298+
created, err := dataStore.CreateCheck(model.Check{
299+
Name: "Dashboard Trigger Test",
300+
URL: "https://example.com",
301+
IntervalSeconds: 60,
302+
TimeoutSeconds: 5,
303+
ExpectedStatusCode: 200,
304+
})
305+
if err != nil {
306+
t.Fatalf("CreateCheck() error = %v", err)
307+
}
308+
309+
server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore)
310+
server.SetTriggerer(&stubTriggerer{result: model.Result{
311+
CheckID: created.ID,
312+
Status: "healthy",
313+
}})
314+
315+
form := url.Values{"redirect_to": []string{"/checks/" + created.ID + "/view"}}
316+
req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/trigger", bytes.NewBufferString(form.Encode()))
317+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
318+
res := httptest.NewRecorder()
319+
server.Handler().ServeHTTP(res, req)
320+
321+
if res.Code != http.StatusSeeOther {
322+
t.Fatalf("dashboard trigger status = %d, want %d", res.Code, http.StatusSeeOther)
323+
}
324+
if loc := res.Header().Get("Location"); loc != "/checks/"+created.ID+"/view" {
325+
t.Fatalf("Location = %q, want detail page", loc)
326+
}
327+
}
328+
329+
// stubTriggerer is a test double for the Triggerer interface.
330+
type stubTriggerer struct {
331+
result model.Result
332+
err error
333+
}
334+
335+
func (s *stubTriggerer) RunNow(_ string) (model.Result, error) {
336+
return s.result, s.err
337+
}

internal/httpapi/templates/detail.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ <h2>Recent history</h2>
224224
<div class="row-meta">Server time: {{.CurrentTime}} • Refreshes every 10 seconds</div>
225225
</div>
226226
<div class="actions">
227+
<form method="post" action="/dashboard/checks/{{.Check.ID}}/trigger">
228+
<input type="hidden" name="redirect_to" value="/checks/{{.Check.ID}}/view">
229+
<button type="submit">Check now</button>
230+
</form>
227231
{{if .Check.Paused}}
228232
<form method="post" action="/dashboard/checks/{{.Check.ID}}/resume">
229233
<input type="hidden" name="redirect_to" value="/checks/{{.Check.ID}}/view">

internal/monitor/service.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,35 +78,48 @@ func (s *Service) tick() {
7878
}
7979
}
8080

81-
func (s *Service) evaluate(check model.Check) {
81+
// RunNow immediately evaluates a check by ID and returns the result.
82+
// Returns an error only for store failures (e.g. store.ErrNotFound); a
83+
// network failure to the monitored URL is captured as a "down" Result.
84+
func (s *Service) RunNow(checkID string) (model.Result, error) {
85+
check, err := s.store.GetCheck(checkID)
86+
if err != nil {
87+
return model.Result{}, err
88+
}
89+
return s.evaluate(check), nil
90+
}
91+
92+
func (s *Service) evaluate(check model.Check) model.Result {
8293
timeout := time.Duration(check.TimeoutSeconds) * time.Second
8394
ctx, cancel := context.WithTimeout(context.Background(), timeout)
8495
defer cancel()
8596

8697
start := time.Now()
8798
req, err := http.NewRequestWithContext(ctx, http.MethodGet, check.URL, nil)
8899
if err != nil {
89-
s.persistResult(check, model.Result{
100+
result := model.Result{
90101
CheckID: check.ID,
91102
CheckedAt: time.Now().UTC(),
92103
Status: "down",
93104
ResponseMS: time.Since(start).Milliseconds(),
94105
ErrorMessage: err.Error(),
95-
})
96-
return
106+
}
107+
s.persistResult(check, result)
108+
return result
97109
}
98110
req.Header.Set("User-Agent", s.userAgent)
99111

100112
resp, err := s.httpClient.Do(req)
101113
if err != nil {
102-
s.persistResult(check, model.Result{
114+
result := model.Result{
103115
CheckID: check.ID,
104116
CheckedAt: time.Now().UTC(),
105117
Status: "down",
106118
ResponseMS: time.Since(start).Milliseconds(),
107119
ErrorMessage: err.Error(),
108-
})
109-
return
120+
}
121+
s.persistResult(check, result)
122+
return result
110123
}
111124
defer resp.Body.Close()
112125

@@ -116,14 +129,16 @@ func (s *Service) evaluate(check model.Check) {
116129
status = "down"
117130
}
118131

119-
s.persistResult(check, model.Result{
132+
result := model.Result{
120133
CheckID: check.ID,
121134
CheckedAt: time.Now().UTC(),
122135
Status: status,
123136
StatusCode: resp.StatusCode,
124137
ResponseMS: time.Since(start).Milliseconds(),
125138
ResponseSample: strings.TrimSpace(string(body)),
126-
})
139+
}
140+
s.persistResult(check, result)
141+
return result
127142
}
128143

129144
func (s *Service) persistResult(check model.Check, result model.Result) {

0 commit comments

Comments
 (0)