Commit ced25dc192

ced25dc1927b3d1496f09c1c1d6cbe005088f96d

parent: 97b29c8fac

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-23 05:55 UTC

web: the build page streams a live build's log

Ref #250
internal/httpd/buildpages_test.go +5 −11
@@ -59,21 +59,15 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) {
5959
6060func TestBuildPageRendersCommandOutput(t *testing.T) {
6161 var sb strings.Builder
62 err := web.Render(&sb, "build.html", struct {
63 repoPage
64 Build control.BuildOut
65 Log string
66 CanWrite bool
67 Notice string
68 }{
69 testRepoPage(),
70 control.BuildOut{
62 err := web.Render(&sb, "build.html", buildView{
63 repoPage: testRepoPage(),
64 Build: control.BuildOut{
7165 Number: 60, Job: "build", Status: "success",
7266 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage",
7367 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
7468 },
75 "step 1 ok",
76 true, "",
69 Log: "step 1 ok",
70 CanWrite: true,
7771 })
7872 if err != nil {
7973 t.Fatalf("render: %v", err)
internal/httpd/builds.go +89 −8
@@ -1,12 +1,20 @@
11package httpd
22
33import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "io"
48 "net/http"
59 "net/url"
610 "slices"
711 "strconv"
12 "strings"
813
914 "gitbay.org/gitbay/internal/control"
15 "gitbay.org/gitbay/internal/protocol"
16 "gitbay.org/gitbay/internal/store"
17 "gitbay.org/gitbay/internal/web"
1018)
1119
1220// buildFilter is the builds page's GET filter: branch, status and job,
@@ -295,13 +303,86 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
295303 s.notFound(w, r)
296304 return
297305 }
298 log, _, _ := s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
306 v := buildView{repoPage: p, Build: b, CanWrite: s.canWriteRepo(r, p.Repo), Notice: s.takeFlash(w, r)}
307 if (b.Status == "pending" || b.Status == "running") && r.URL.Query().Get("follow") != "0" {
308 s.streamBuild(w, r, v, viewer, n)
309 return
310 }
311 v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
312 s.render(w, "build.html", v)
313}
299314
300 s.render(w, "build.html", struct {
301 repoPage
302 Build control.BuildOut
303 Log string
304 CanWrite bool
305 Notice string
306 }{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
315type buildView struct {
316 repoPage
317 Build control.BuildOut
318 Log string
319 Live bool
320 CanWrite bool
321 Notice string
322}
323
324// liveLogMarker stands in for the log when build.html is rendered for a
325// live build; streamBuild splits the page there and streams the log into
326// the gap. Git refs, paths and job names cannot hold the control byte.
327const liveLogMarker = "\x1elive-log\x1e"
328
329// streamBuild writes the build page with the log following the build:
330// the page up to the log, then build log --follow escaped and flushed as
331// it arrives, then the outcome and the rest of the page.
332func (s *Server) streamBuild(w http.ResponseWriter, r *http.Request, v buildView, viewer store.User, n string) {
333 v.Live, v.Log = true, liveLogMarker
334 var buf bytes.Buffer
335 if err := web.Render(&buf, "build.html", v); err != nil {
336 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
337 return
338 }
339 head, tail, ok := strings.Cut(buf.String(), liveLogMarker)
340 if !ok || !strings.HasPrefix(tail, "</pre>") {
341 http.Error(w, "template error: build.html has no live log slot", http.StatusInternalServerError)
342 return
343 }
344 tail = strings.TrimPrefix(tail, "</pre>")
345
346 h := w.Header()
347 h.Set("Content-Type", "text/html; charset=utf-8")
348 h.Set("Cache-Control", "no-store")
349 h.Set("X-Accel-Buffering", "no")
350 rc := http.NewResponseController(w)
351 io.WriteString(w, head)
352 rc.Flush()
353
354 path := v.Repo.Path()
355 msg, code := s.runControlStream(viewer, []string{"build", "log", path, n, "--follow"},
356 htmlStream{w: w, rc: rc}, r.Context().Done())
357 if code == protocol.ExitDenied {
358 // The follow cap: the stored log once, and why it is not live.
359 log, _, _ := s.runControl(viewer, []string{"build", "log", path, n})
360 template.HTMLEscape(w, []byte(log))
361 }
362 io.WriteString(w, "</pre>")
363 switch {
364 case code == protocol.ExitOK:
365 var b control.BuildOut
366 if _, ok := s.runControlInto(viewer, []string{"build", "show", path, n}, &b); ok {
367 fmt.Fprintf(w, `<p class="notice" role="status">build finished: %s</p>`, template.HTMLEscapeString(b.Status))
368 }
369 case code == protocol.ExitDenied:
370 fmt.Fprintf(w, `<p class="error" role="alert">%s</p>`, template.HTMLEscapeString(msg))
371 }
372 io.WriteString(w, tail)
373}
374
375// htmlStream escapes each chunk of a streamed log into the page and
376// flushes it, so the browser draws it as it arrives.
377type htmlStream struct {
378 w io.Writer
379 rc *http.ResponseController
380}
381
382func (h htmlStream) Write(p []byte) (int, error) {
383 template.HTMLEscape(h.w, p)
384 if err := h.rc.Flush(); err != nil {
385 return 0, err
386 }
387 return len(p), nil
307388}
internal/httpd/control.go +22
@@ -3,6 +3,7 @@ package httpd
33import (
44 "bytes"
55 "encoding/json"
6 "io"
67 "net/http"
78 "strings"
89
@@ -50,6 +51,27 @@ func (s *Server) runControlCode(u store.User, argv []string) (out string, msg st
5051 return stdout.String(), m, code
5152}
5253
54// runControlStream runs a command whose output is written as it is
55// produced: stdout goes to out, and done ends the command when the
56// request does. msg is stderr.
57func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, done <-chan struct{}) (msg string, code int) {
58 var stderr bytes.Buffer
59 ctx := &control.Ctx{
60 User: u,
61 Source: "web",
62 Scope: "full",
63 Store: s.st,
64 Cfg: s.cfg,
65 Stdin: strings.NewReader(""),
66 Stdout: out,
67 Stderr: &stderr,
68 ViaAPI: true,
69 Done: done,
70 }
71 code = control.Dispatch(ctx, argv)
72 return strings.TrimSpace(stderr.String()), code
73}
74
5375// done finishes a form action by exit code: back to the page on success,
5476// the 404 page when the thing does not exist, and back to the page with
5577// the message for anything else. A refusal is feedback on the page a
internal/web/templates/build.html +3 −1
@@ -12,5 +12,7 @@
1212{{end}}
1313</div>
1414<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>
15{{if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
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>
17{{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
1618{{end}}