fix(web): job log polls while active, one service history limit (#7)

This commit is contained in:
2026-10-12 19:13:01 +02:00
parent 88d5507217
commit 8b2c10cc05
3 changed files with 22 additions and 5 deletions
+5 -2
View File
@@ -23,6 +23,9 @@ import (
//go:embed templates/*.html static/* //go:embed templates/*.html static/*
var assets embed.FS 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}$`) 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. // 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 { } else if err != nil {
return data, err 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 return data, err
} }
@@ -225,7 +228,7 @@ func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return 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 { if err != nil {
s.fail(w, err) s.fail(w, err)
return return
+13
View File
@@ -482,6 +482,19 @@ func TestJobFragment_RereadsFromStore(t *testing.T) {
assert.Contains(t, body, "step 2") 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) { func TestJob_NotFound(t *testing.T) {
srv, _, _, _ := newTestServer(t) srv, _, _, _ := newTestServer(t)
for _, path := range []string{"/jobs/99", "/fragments/jobs/99"} { for _, path := range []string{"/jobs/99", "/fragments/jobs/99"} {
+3 -2
View File
@@ -1,12 +1,12 @@
{{define "content"}} {{define "content"}}
<h1>Job #{{.Job.ID}}</h1> <h1>Job #{{.Job.ID}}</h1>
<p><a href="/services/{{.Job.ServiceID}}">{{.Job.ServiceName}}</a></p> <p><a href="/services/{{.Job.ServiceID}}">{{.Job.ServiceName}}</a></p>
<div id="job" hx-get="/fragments/jobs/{{.Job.ID}}" hx-trigger="sse:job-{{.Job.ID}}" hx-swap="innerHTML">
{{template "job" .}} {{template "job" .}}
</div>
{{end}} {{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"}} {{define "job"}}
<div id="job" hx-get="/fragments/jobs/{{.Job.ID}}" hx-trigger="sse:job-{{.Job.ID}}{{if .Job.Active}}, every 2s{{end}}" hx-swap="outerHTML">
<table class="details"> <table class="details">
<tr><th>Status</th><td class="status status-job-{{.Job.Status}}">{{.Job.Status}}</td></tr> <tr><th>Status</th><td class="status status-job-{{.Job.Status}}">{{.Job.Status}}</td></tr>
<tr><th>From</th><td><code>{{shortRef .Job.FromRef}}</code></td></tr> <tr><th>From</th><td><code>{{shortRef .Job.FromRef}}</code></td></tr>
@@ -18,4 +18,5 @@
{{if .Job.Error}}<p class="error">{{.Job.Error}}</p>{{end}} {{if .Job.Error}}<p class="error">{{.Job.Error}}</p>{{end}}
{{if .Job.OutputTruncated}}<p class="muted">Earlier output truncated.</p>{{end}} {{if .Job.OutputTruncated}}<p class="muted">Earlier output truncated.</p>{{end}}
<pre class="log">{{.Job.Output}}</pre> <pre class="log">{{.Job.Output}}</pre>
</div>
{{end}} {{end}}