From 8b2c10cc058bcbed8bb5f00d0b9493aa4b3d1824 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 12 Oct 2026 19:13:01 +0200 Subject: [PATCH] fix(web): job log polls while active, one service history limit (#7) --- internal/master/web/server.go | 7 +++++-- internal/master/web/server_test.go | 13 +++++++++++++ internal/master/web/templates/job.html | 7 ++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/master/web/server.go b/internal/master/web/server.go index 761d684..ce5c4e0 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -23,6 +23,9 @@ import ( //go:embed templates/*.html static/* var assets embed.FS +// serviceJobsLimit is how many jobs the service page lists. +const serviceJobsLimit = 20 + var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) // Store is the storage the UI reads and writes. *store.Store satisfies it. @@ -203,7 +206,7 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) { } else if err != nil { return data, err } - data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: 20}) + data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit}) return data, err } @@ -225,7 +228,7 @@ func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) { if !ok { return } - jobs, err := s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: 20}) + jobs, err := s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit}) if err != nil { s.fail(w, err) return diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index 9cb0c26..ba4686b 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -482,6 +482,19 @@ func TestJobFragment_RereadsFromStore(t *testing.T) { assert.Contains(t, body, "step 2") } +func TestJobFragment_PollsOnlyWhileActive(t *testing.T) { + srv, store, _, _, _ := newTestEnv(t) + store.jobs = []model.Job{ + {ID: 42, ServiceID: 1, Status: model.JobRunning, CreatedAt: time.Now()}, + {ID: 43, ServiceID: 1, Status: model.JobSucceeded, CreatedAt: time.Now()}, + } + _, body := get(t, srv, "/fragments/jobs/42") + assert.Contains(t, body, `hx-trigger="sse:job-42, every 2s"`) + _, body = get(t, srv, "/fragments/jobs/43") + assert.Contains(t, body, `hx-trigger="sse:job-43"`) + assert.NotContains(t, body, "every 2s") +} + func TestJob_NotFound(t *testing.T) { srv, _, _, _ := newTestServer(t) for _, path := range []string{"/jobs/99", "/fragments/jobs/99"} { diff --git a/internal/master/web/templates/job.html b/internal/master/web/templates/job.html index b22eee6..e90353e 100644 --- a/internal/master/web/templates/job.html +++ b/internal/master/web/templates/job.html @@ -1,12 +1,12 @@ {{define "content"}}

Job #{{.Job.ID}}

{{.Job.ServiceName}}

-
- {{template "job" .}} -
+{{template "job" .}} {{end}} +{{/* The wrapper is part of the fragment and swapped as outerHTML, so a finished job drops the polling timer. The timer recovers from a dropped SSE signal. */}} {{define "job"}} +
@@ -18,4 +18,5 @@ {{if .Job.Error}}

{{.Job.Error}}

{{end}} {{if .Job.OutputTruncated}}

Earlier output truncated.

{{end}}
{{.Job.Output}}
+ {{end}}
Status{{.Job.Status}}
From{{shortRef .Job.FromRef}}