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 |
| 198 | } |
198 | } |
| 199 | return sb.String() |
199 | return sb.String() |
| 200 | } |
200 | } |
| |
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). |
| |
204 | func 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 ( |
| 10 | "slices" |
10 | "slices" |
| 11 | "strconv" |
11 | "strconv" |
| 12 | "strings" |
12 | "strings" |
| |
13 | "time" |
| 13 | |
14 | |
| 14 | "gitbay.org/gitbay/internal/control" |
15 | "gitbay.org/gitbay/internal/control" |
| 15 | "gitbay.org/gitbay/internal/protocol" |
16 | "gitbay.org/gitbay/internal/protocol" |
| @@ -282,18 +283,54 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
| 282 | return |
283 | return |
| 283 | } |
284 | } |
| 284 | v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) |
285 | 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 | } |
| 285 | s.render(w, "build.html", v) |
290 | s.render(w, "build.html", v) |
| 286 | } |
291 | } |
| 287 | |
292 | |
| 288 | type buildView struct { |
293 | type buildView struct { |
| 289 | repoPage |
294 | repoPage |
| 290 | Build control.BuildOut |
295 | Build control.BuildOut |
| 291 | Log string |
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 |
| 292 | Live bool |
302 | Live bool |
| 293 | CanWrite bool |
303 | CanWrite bool |
| 294 | Notice string |
304 | Notice string |
| 295 | } |
305 | } |
| 296 | |
306 | |
| |
307 | type 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. |
| |
315 | func 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 | |
| 297 | // liveLogMarker stands in for the log when build.html is rendered for a |
334 | // liveLogMarker stands in for the log when build.html is rendered for a |
| 298 | // live build; streamBuild splits the page there and streams the log into |
335 | // live build; streamBuild splits the page there and streams the log into |
| 299 | // the gap. Git refs, paths and job names cannot hold the control byte. |
336 | // 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 |
| 1113 | p.pager { margin-top: var(--sp-4); } |
1113 | p.pager { margin-top: var(--sp-4); } |
| 1114 | .snippetfile { margin-bottom: var(--sp-5); } |
1114 | .snippetfile { margin-bottom: var(--sp-5); } |
| 1115 | p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } |
1115 | p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } |
| 1116 | pre.buildlog { max-height: 40rem; overflow: auto; } |
1116 | pre.buildlog { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } |
| |
1117 | details.buildstep pre.buildlog { margin: 0; border: 0; border-radius: 0; } |
| |
1118 | details.buildstep summary code { overflow-wrap: anywhere; } |
| 1117 | |
1119 | |
| 1118 | /* ---- rendered prose ---- */ |
1120 | /* ---- rendered prose ---- */ |
| 1119 | pre { |
1121 | pre { |
internal/web/templates/build.html
+6 −1
| @@ -11,8 +11,13 @@ |
| 11 | </form> |
11 | </form> |
| 12 | {{end}} |
12 | {{end}} |
| 13 | </div> |
13 | </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> |
| 15 | {{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> |
15 | {{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> |
| 16 | <pre class="code buildlog" tabindex="0">{{.Log}}</pre> |
16 | <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}} |
| 17 | {{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} |
22 | {{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} |
| 18 | {{end}} |
23 | {{end}} |