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. | |