Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Tail run logs in the browser and make steps accordions

34d6fd7a9210fca265ea4aea1d5733b3eeeb2569 · Keenan Nemetz · 10d ago · parent 5ece70a5

Tail run logs in the browser and make steps accordions

The log endpoint now serves from a byte offset and marks an attempt's log final,
so the run page can poll only while the job runs. The job output accordion follows
the stream; each step is its own accordion with its slice or a clear empty state.
The steps table is gone; the logs card is the whole viewer.

server/integration_test.go +78−1

@@ -2890,7 +2890,7 @@ jobs:
2890 2890 }
2891 2891 // Attribution is the boundary's, and the page says who it accepted the result from.
2892 2892 if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "reported by") ||
2893 !strings.Contains(page, "say hello") || !strings.Contains(page, "?step=1") {
2893 !strings.Contains(page, "say hello") || !strings.Contains(page, "step=1") {
2894 2894 t.Fatalf("the run page does not show the result, the step, or its log link:\n%s", page)
2895 2895 }
2896 2896 if done.SHA != mustSHA(t, work) {
@@ -2898,6 +2898,83 @@ jobs:
2898 2898 }
2899 2899 }
2900 2900
2901 // TestRunLogStreamsWhileTheJobRuns is the point of streaming: the log endpoint
2902 // serves the first step's bytes before the job finishes, and the finished log still
2903 // contains everything.
2904 func TestRunLogStreamsWhileTheJobRuns(t *testing.T) {
2905 t.Parallel()
2906 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
2907 f.publish(f.repo)
2908
2909 work := workRepo(t, workflowFile(`version: 1
2910 jobs:
2911 test:
2912 image: alpine:3.22
2913 steps:
2914 - name: slow
2915 run: echo "first line"; sleep 4; echo "second line"
2916 `))
2917 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2918 t.Fatalf("push: %s", errs)
2919 }
2920 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
2921
2922 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
2923 cmd.Env = f.ciEnv()
2924 if err := cmd.Start(); err != nil {
2925 t.Fatal(err)
2926 }
2927 defer func() {
2928 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
2929 _, _ = cmd.Process.Wait()
2930 }()
2931
2932 run := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusRunning })
2933 deadline := time.Now().Add(8 * time.Second)
2934 for {
2935 code, body := f.get("/r/" + f.repo + "/runs/" + run.ID + "/log?after=0")
2936 if code == 200 && strings.Contains(body, "first line") {
2937 break
2938 }
2939 if time.Now().After(deadline) {
2940 t.Fatalf("the running log never showed the first line: %d %q", code, body)
2941 }
2942 time.Sleep(100 * time.Millisecond)
2943 }
2944 if _, page := f.get("/r/" + f.repo + "/runs/" + run.ID); !strings.Contains(page, "data-log-stream") ||
2945 !strings.Contains(page, "Job output") || !strings.Contains(page, "slow") {
2946 t.Fatalf("the running run page does not show the live log viewer or step accordion:\n%s", page)
2947 }
2948 // The live viewer polls; a running attempt must not claim the log is final.
2949 resp, err := http.Get(f.http.URL + "/r/" + f.repo + "/runs/" + run.ID + "/log?after=0")
2950 if err != nil {
2951 t.Fatal(err)
2952 }
2953 if final := resp.Header.Get("X-Log-Final"); final == "1" {
2954 t.Fatalf("a running attempt's log is marked final")
2955 }
2956 resp.Body.Close()
2957
2958 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
2959 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
2960 })
2961 if done.Status != runs.StatusSuccess {
2962 t.Fatalf("run status = %s (exit %d)", done.Status, done.ExitCode)
2963 }
2964 code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
2965 if code != 200 || !strings.Contains(body, "first line") || !strings.Contains(body, "second line") {
2966 t.Fatalf("finished log = %d, body:\n%s", code, body)
2967 }
2968 resp, err = http.Get(f.http.URL + "/r/" + f.repo + "/runs/" + done.ID + "/log?after=0")
2969 if err != nil {
2970 t.Fatal(err)
2971 }
2972 if final := resp.Header.Get("X-Log-Final"); final != "1" {
2973 t.Fatalf("a finished attempt's log is not marked final")
2974 }
2975 resp.Body.Close()
2976 }
2977
2901 2978 // A workflow can name a definition under .vole/images. The queue pins the
2902 2979 // definition, the runner builds or finds the digest tag, and the record says which
2903 2980 // definition ran.