Commit ec5fe62412

ec5fe624126e54a6146aa5f1f71e33eee4570fd8

parent: e6b51c178e

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:01 UTC

web: build log folded by step, failed step open

Ref #266

Layout: unified · split

internal/httpd/buildpages_test.go +32
@@ -198,3 +198,35 @@ func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, o
198198 }
199199 return sb.String()
200200}
201
202// A failed build's page folds its log by step, opens the step that
203// failed and links to it; no JavaScript (#266).
204func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) {
205 b := control.BuildOut{Number: 61, Job: "test", Status: "failure",
206 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
207 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656,
208 Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"}
209 log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n"
210 v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"}
211 v.Steps, v.Failed = logSteps(log, b)
212 var sb strings.Builder
213 if err := web.Render(&sb, "build.html", v); err != nil {
214 t.Fatalf("render: %v", err)
215 }
216 out := sb.String()
217 for _, want := range []string{
218 `<details class="difffold buildstep" id="failed" open>`,
219 "step 2/2", "<code>go test ./...</code>", `href="#failed"`, "Jump to failure",
220 "ran 10m56s", "--- FAIL: TestCLI",
221 } {
222 if !strings.Contains(out, want) {
223 t.Errorf("build.html missing %q", want)
224 }
225 }
226 if n := strings.Count(out, `class="difffold buildstep"`); n != 3 {
227 t.Errorf("%d step folds, want 3 (setup and two steps)", n)
228 }
229 if n := strings.Count(out, `id="failed"`); n != 1 {
230 t.Errorf("%d failed anchors, want 1", n)
231 }
232}
internal/httpd/builds.go +39 −2
@@ -10,6 +10,7 @@ import (
1010 "slices"
1111 "strconv"
1212 "strings"
13 "time"
1314
1415 "gitbay.org/gitbay/internal/control"
1516 "gitbay.org/gitbay/internal/protocol"
@@ -282,18 +283,54 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
282283 return
283284 }
284285 v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
286 v.Steps, v.Failed = logSteps(v.Log, b)
287 if b.DurationS > 0 {
288 v.Duration = (time.Duration(b.DurationS) * time.Second).String()
289 }
285290 s.render(w, "build.html", v)
286291}
287292
288293type buildView struct {
289294 repoPage
290 Build control.BuildOut
291 Log string
295 Build control.BuildOut
296 Log string
297 // Steps is the finished log cut at its steps, nil when there is no
298 // step to cut at; Failed says whether one of them is marked failed.
299 Steps []logStep
300 Failed bool
301 Duration string
292302 Live bool
293303 CanWrite bool
294304 Notice string
295305}
296306
307type logStep struct {
308 control.LogSection
309 Failed bool
310}
311
312// logSteps cuts a finished build's log at its steps and marks the one it
313// failed at. Nil when no step's line is in the log — a build that
314// stopped in the clone — which renders as one block.
315func logSteps(log string, b control.BuildOut) ([]logStep, bool) {
316 sections := control.SplitBuildLog(log, b.Steps)
317 stepped := false
318 for _, s := range sections {
319 if s.N > 0 {
320 stepped = true
321 }
322 }
323 if !stepped {
324 return nil, false
325 }
326 failed := control.FailedSection(sections, b.Status, b.FailedStep)
327 out := make([]logStep, len(sections))
328 for i, s := range sections {
329 out[i] = logStep{LogSection: s, Failed: i == failed}
330 }
331 return out, failed >= 0
332}
333
297334// liveLogMarker stands in for the log when build.html is rendered for a
298335// live build; streamBuild splits the page there and streams the log into
299336// the gap. Git refs, paths and job names cannot hold the control byte.
internal/web/static/style.css +3 −1
@@ -1113,7 +1113,9 @@ pre.matchline mark { background: color-mix(in srgb, var(--mark) 25%, transparent
11131113p.pager { margin-top: var(--sp-4); }
11141114.snippetfile { margin-bottom: var(--sp-5); }
11151115p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
1116pre.buildlog { max-height: 40rem; overflow: auto; }
1116pre.buildlog { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
1117details.buildstep pre.buildlog { margin: 0; border: 0; border-radius: 0; }
1118details.buildstep summary code { overflow-wrap: anywhere; }
11171119
11181120/* ---- rendered prose ---- */
11191121pre {
internal/web/templates/build.html +6 −1
@@ -11,8 +11,13 @@
1111</form>
1212{{end}}
1313</div>
14<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
14<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p>
1515{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
1616<pre class="code buildlog" tabindex="0">{{.Log}}</pre>
17{{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}}
18<details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}>
19 <summary>{{if .N}}<span>step {{.N}}/{{$total}}</span> <code>{{.Step}}</code>{{else}}<span>setup</span>{{end}}{{if .Failed}} <span class="chip check-failure">failed</span>{{end}}</summary>
20 <pre class="code buildlog" tabindex="0">{{.Text}}</pre>
21</details>{{end}}
1722{{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
1823{{end}}