dashboard: build runs in the feed, relative times; the rail strip tidied !409

merged merged by cmc on 2026-09-18 20:16 UTC · krz/gitbay:feed-222 into main

9 files changed, +323 −18

Layout: unified · split

e2e/dashboard_test.go +53
@@ -356,3 +356,56 @@ func TestDashboardQueues(t *testing.T) {
356 t.Fatalf("reviewed MR still waiting:\n%s", after) 356 t.Fatalf("reviewed MR still waiting:\n%s", after)
357 } 357 }
358} 358}
359
360// D04/D05: two jobs on one commit fold into a single feed line, marked
361// with the worse of the two outcomes, and shown with a relative time
362// carrying the exact UTC time in its title.
363func TestDashboardFeedFoldsBuildRun(t *testing.T) {
364 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
365 inst.runner = buildRunner(t)
366 aliceKey := inst.newKey(t, "alice")
367 inst.admin(t, "admin", "user", "create", "alice",
368 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
369 runnerKey := inst.newKey(t, "ci")
370 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
371
372 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
373 t.Fatalf("repo create: %s", errOut)
374 }
375 work := t.TempDir()
376 env := inst.gitEnv(aliceKey)
377 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
378 dir := filepath.Join(work, "w")
379 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
380 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
381 "jobs:\n ok:\n steps:\n - echo fine\n broken:\n steps:\n - \"false\"\n"), 0o644)
382 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
383 mustGit(t, dir, env, "add", ".")
384 mustGit(t, dir, env, "commit", "-q", "-m", "base")
385 mustGit(t, dir, env, "push", "-q", "origin", "main")
386 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
387
388 // The runner processes both jobs ("broken" sorts first).
389 inst.runnerOnce(t, runnerKey)
390 inst.runnerOnce(t, runnerKey)
391
392 _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
393 if !strings.Contains(body, "ran 2 jobs on") {
394 t.Fatalf("feed did not fold the two jobs into one run:\n%s", body)
395 }
396 if !strings.Contains(body, `class="dot bad"`) {
397 t.Fatalf("feed did not mark the run with the worse (failure) status:\n%s", body)
398 }
399 if !strings.Contains(body, sha[:10]) {
400 t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body)
401 }
402 // A build reported moments ago renders as "just now"; ago() only
403 // switches to "N ago" past a minute, so either form proves the
404 // relative-time rendering rather than the raw timestamp.
405 if !strings.Contains(body, ">just now<") && !strings.Contains(body, " ago<") {
406 t.Fatalf("feed missing a relative time:\n%s", body)
407 }
408 if !strings.Contains(body, "title=\"") || !strings.Contains(body, " UTC\"") {
409 t.Fatalf("feed missing the exact time in a title:\n%s", body)
410 }
411}
internal/control/build.go +3 −3
@@ -593,7 +593,7 @@ func runRunnerDone(c *Ctx, args []string) int {
593 return c.fail(protocol.ExitFailure, "%v", err) 593 return c.fail(protocol.ExitFailure, "%v", err)
594 } 594 }
595 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1], 595 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
596 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) 596 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
597 // A red build mails the repo's notify targets with the log tail — a 597 // A red build mails the repo's notify targets with the log tail — a
598 // failed scheduled job must not wait to be noticed. 598 // failed scheduled job must not wait to be noticed.
599 if args[1] == "failure" { 599 if args[1] == "failure" {
@@ -829,7 +829,7 @@ func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int {
829 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf( 829 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf(
830 "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA))) 830 "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA)))
831 resolveCancelledCommitStatus(c, repo, b) 831 resolveCancelledCommitStatus(c, repo, b)
832 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) 832 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
833 return -1 833 return -1
834} 834}
835 835
@@ -858,7 +858,7 @@ func runBuildCancel(c *Ctx, args []string) int {
858 } 858 }
859 // The queued status replaced whatever the commit had for this job. 859 // The queued status replaced whatever the commit had for this job.
860 resolveCancelledCommitStatus(c, repo, b) 860 resolveCancelledCommitStatus(c, repo, b)
861 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) 861 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
862 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) { 862 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
863 if b.Status == "running" { 863 if b.Status == "running" {
864 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job) 864 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
internal/httpd/builds.go +20 −2
@@ -99,15 +99,33 @@ var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
99// combinedStatus is the run's status: the worst of its builds' statuses, 99// combinedStatus is the run's status: the worst of its builds' statuses,
100// success only when every one of them is. 100// success only when every one of them is.
101func combinedStatus(builds []control.BuildOut) string { 101func combinedStatus(builds []control.BuildOut) string {
102 statuses := make([]string, len(builds))
103 for i, b := range builds {
104 statuses[i] = b.Status
105 }
106 return worstStatus(statuses)
107}
108
109// worstStatus is combinedStatus's ordering rule, factored out so the
110// dashboard feed can apply the same worst-first precedence to a folded
111// build run (D04). A status outside runStatusPriority (a future state
112// such as "skipped") is still not "success": it is returned unchanged
113// rather than falling through and reading as green.
114func worstStatus(statuses []string) string {
102 has := map[string]bool{} 115 has := map[string]bool{}
103 for _, b := range builds { 116 for _, s := range statuses {
104 has[b.Status] = true 117 has[s] = true
105 } 118 }
106 for _, s := range runStatusPriority { 119 for _, s := range runStatusPriority {
107 if has[s] { 120 if has[s] {
108 return s 121 return s
109 } 122 }
110 } 123 }
124 for _, s := range statuses {
125 if s != "success" {
126 return s
127 }
128 }
111 return "success" 129 return "success"
112} 130}
113 131
internal/httpd/builds_test.go +4
@@ -55,6 +55,10 @@ func TestGroupRunsCombinedStatusPriority(t *testing.T) {
55 {[]string{"running", "cancelled"}, "cancelled"}, 55 {[]string{"running", "cancelled"}, "cancelled"},
56 {[]string{"cancelled", "failure"}, "failure"}, 56 {[]string{"cancelled", "failure"}, "failure"},
57 {[]string{"success", "success", "failure"}, "failure"}, 57 {[]string{"success", "success", "failure"}, "failure"},
58 // A status outside runStatusPriority (a future state such as
59 // "skipped") is still not success: it must not fall through to
60 // the "success" default and read as green.
61 {[]string{"success", "skipped"}, "skipped"},
58 } 62 }
59 for _, tc := range cases { 63 for _, tc := range cases {
60 var builds []control.BuildOut 64 var builds []control.BuildOut
internal/httpd/feed.go +47 −5
@@ -4,6 +4,7 @@ import (
4 "encoding/json" 4 "encoding/json"
5 "fmt" 5 "fmt"
6 "strings" 6 "strings"
7 "time"
7 8
8 "gitbay.org/gitbay/internal/store" 9 "gitbay.org/gitbay/internal/store"
9) 10)
@@ -11,30 +12,53 @@ import (
11// feedLine is one activity entry, already phrased and linked. 12// feedLine is one activity entry, already phrased and linked.
12type feedLine struct { 13type feedLine struct {
13 Actor string 14 Actor string
14 Verb string // "opened issue", "merged" 15 Verb string // "opened issue", "merged", "ran 2 jobs on"
15 Ref string // "#12", "!35", "v0.4.0" 16 Ref string // "#12", "!35", "v0.4.0", a short sha
16 Repo string 17 Repo string
17 URL string 18 URL string
18 When string 19 When string // the stored timestamp, for anything still reading it raw
20 WhenT time.Time // parsed from When, for ago/whenT rendering
21 State string // a build run's combined status; empty for anything else
22 Jobs []string // job names folded into a build run
23 sha string // the commit a build event fired on, for fold-matching
19} 24}
20 25
21// feedLines turns stored events into readable lines. An unknown kind 26// feedLines turns stored events into readable lines. An unknown kind
22// still shows: the feed says what happened even for events added later. 27// still shows: the feed says what happened even for events added later.
28// Build events on the same commit, adjacent in the input, fold into one
29// "run" line (D04): its State is the worst of the folded jobs' outcomes,
30// via worstStatus — the same rule the builds tab uses for a run's status.
23func feedLines(events []store.FeedEvent) []feedLine { 31func feedLines(events []store.FeedEvent) []feedLine {
24 out := make([]feedLine, 0, len(events)) 32 out := make([]feedLine, 0, len(events))
33 statuses := make([][]string, 0, len(events))
25 for _, e := range events { 34 for _, e := range events {
26 var d struct { 35 var d struct {
27 Number int64 `json:"number"` 36 Number int64 `json:"number"`
28 Job string `json:"job"` 37 Job string `json:"job"`
29 Tag string `json:"tag"` 38 Tag string `json:"tag"`
39 SHA string `json:"sha"`
30 } 40 }
31 json.Unmarshal([]byte(e.Data), &d) 41 json.Unmarshal([]byte(e.Data), &d)
42 kind, rest, _ := strings.Cut(e.Kind, ".")
43
44 if kind == "build" && d.SHA != "" {
45 if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath {
46 i := n - 1
47 out[i].Jobs = append(out[i].Jobs, d.Job)
48 statuses[i] = append(statuses[i], rest)
49 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
50 out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
51 out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
52 out[i].State = worstStatus(statuses[i])
53 continue
54 }
55 }
32 56
33 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt} 57 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
34 if l.Actor == "" { 58 if l.Actor == "" {
35 l.Actor = "gitbay" 59 l.Actor = "gitbay"
36 } 60 }
37 kind, rest, _ := strings.Cut(e.Kind, ".") 61 var st []string
38 switch kind { 62 switch kind {
39 case "issue": 63 case "issue":
40 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) 64 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
@@ -45,6 +69,12 @@ func feedLines(events []store.FeedEvent) []feedLine {
45 case "build": 69 case "build":
46 l.Verb, l.Ref = "build "+rest, d.Job 70 l.Verb, l.Ref = "build "+rest, d.Job
47 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number) 71 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
72 if d.SHA != "" {
73 l.sha = d.SHA
74 l.Jobs = []string{d.Job}
75 l.State = rest
76 st = []string{rest}
77 }
48 case "release": 78 case "release":
49 l.Verb, l.Ref = "released", d.Tag 79 l.Verb, l.Ref = "released", d.Tag
50 l.URL = fmt.Sprintf("/%s/releases", e.RepoPath) 80 l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
@@ -56,10 +86,22 @@ func feedLines(events []store.FeedEvent) []feedLine {
56 l.URL = "/" + e.RepoPath 86 l.URL = "/" + e.RepoPath
57 } 87 }
58 out = append(out, l) 88 out = append(out, l)
89 statuses = append(statuses, st)
59 } 90 }
60 return out 91 return out
61} 92}
62 93
94// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
95// rendering; an unparseable value (or none) comes back zero rather than
96// guessing.
97func parseEventTime(s string) time.Time {
98 t, err := time.Parse(time.RFC3339Nano, s)
99 if err != nil {
100 return time.Time{}
101 }
102 return t
103}
104
63func issueVerb(s string) string { 105func issueVerb(s string) string {
64 switch s { 106 switch s {
65 case "created": 107 case "created":
internal/httpd/feed_test.go added +156
@@ -0,0 +1,156 @@
1package httpd
2
3import (
4 "testing"
5 "time"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// D04: build events on the same commit fold into one feed line, a "run",
11// whose State is the worst of the folded jobs' outcomes.
12func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
13 events := []store.FeedEvent{
14 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
15 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
16 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
17 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
18 }
19 lines := feedLines(events)
20 if len(lines) != 1 {
21 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
22 }
23 l := lines[0]
24 if l.State != "failure" {
25 t.Errorf("State = %q, want failure", l.State)
26 }
27 if l.Verb != "ran 2 jobs on" {
28 t.Errorf("Verb = %q, want %q", l.Verb, "ran 2 jobs on")
29 }
30 if l.Ref != "aaaaaaaaaa" {
31 t.Errorf("Ref = %q, want short sha", l.Ref)
32 }
33 if l.URL != "/alice/app/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
34 t.Errorf("URL = %q", l.URL)
35 }
36 if len(l.Jobs) != 2 || l.Jobs[0] != "unit" || l.Jobs[1] != "lint" {
37 t.Errorf("Jobs = %+v", l.Jobs)
38 }
39}
40
41// A single build event with a sha still gets a State, and keeps its
42// ordinary verb/ref/url — a run of one job reads the same as before.
43func TestFeedLinesSingleBuildGetsState(t *testing.T) {
44 events := []store.FeedEvent{
45 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
46 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
47 }
48 lines := feedLines(events)
49 if len(lines) != 1 {
50 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
51 }
52 l := lines[0]
53 if l.State != "success" {
54 t.Errorf("State = %q, want success", l.State)
55 }
56 if l.Verb != "build success" || l.Ref != "unit" || l.URL != "/alice/app/builds/1" {
57 t.Errorf("single build line changed shape: %+v", l)
58 }
59}
60
61// Two different commits never fold, even back to back.
62func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
63 events := []store.FeedEvent{
64 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
65 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
66 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
67 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
68 }
69 lines := feedLines(events)
70 if len(lines) != 2 {
71 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
72 }
73}
74
75// An event with no sha (an older event recorded before this field existed)
76// never folds into anything, even when it shares a repo with an adjacent
77// build event.
78func TestFeedLinesNoSHANeverFolds(t *testing.T) {
79 events := []store.FeedEvent{
80 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
81 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
82 }
83 lines := feedLines(events)
84 if len(lines) != 2 {
85 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
86 }
87 if lines[0].State != "" || lines[1].State != "" {
88 t.Errorf("shaless build lines got a State: %+v", lines)
89 }
90}
91
92// A non-build event between two builds of the same commit breaks the
93// fold: only adjacent build events on the same commit combine.
94func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
95 events := []store.FeedEvent{
96 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
97 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
98 {RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":1}`},
99 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
100 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
101 }
102 lines := feedLines(events)
103 if len(lines) != 3 {
104 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
105 }
106}
107
108// worstStatus governs the run's combined State the same way it governs
109// combinedStatus for the builds tab.
110func TestFeedLinesRunStatePrecedence(t *testing.T) {
111 cases := []struct {
112 statuses []string
113 want string
114 }{
115 {[]string{"success", "success"}, "success"},
116 {[]string{"success", "pending"}, "pending"},
117 {[]string{"pending", "running"}, "running"},
118 {[]string{"running", "cancelled"}, "cancelled"},
119 {[]string{"cancelled", "failure"}, "failure"},
120 }
121 for _, tc := range cases {
122 events := make([]store.FeedEvent, len(tc.statuses))
123 for i, s := range tc.statuses {
124 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
125 Data: `{"number":1,"job":"j","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}
126 }
127 lines := feedLines(events)
128 if len(lines) != 1 || lines[0].State != tc.want {
129 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
130 }
131 }
132}
133
134// D05: feedLines parses the stored RFC3339 timestamp into WhenT for the
135// template's relative-time rendering; an unparseable value leaves it zero
136// rather than panicking or guessing.
137func TestFeedLinesParsesWhenT(t *testing.T) {
138 events := []store.FeedEvent{
139 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
140 Data: `{"number":1}`, CreatedAt: "2026-09-10T12:00:00Z"},
141 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
142 Data: `{"number":2}`, CreatedAt: "not-a-time"},
143 }
144 lines := feedLines(events)
145 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z")
146 if !lines[0].WhenT.Equal(want) {
147 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want)
148 }
149 if !lines[1].WhenT.IsZero() {
150 t.Errorf("WhenT for bad timestamp = %v, want zero", lines[1].WhenT)
151 }
152 // When is preserved for anything that still reads the raw string.
153 if lines[0].When != "2026-09-10T12:00:00Z" {
154 t.Errorf("When = %q", lines[0].When)
155 }
156}
internal/web/static/style.css +38 −6
@@ -306,6 +306,18 @@ ul.raillist a[aria-current] {
306 font-weight: 600; 306 font-weight: 600;
307} 307}
308ul.raillist .owner { color: var(--shell-muted); } 308ul.raillist .owner { color: var(--shell-muted); }
309ul.raillist a.newrepo {
310 display: inline-flex;
311 width: 28px;
312 height: 28px;
313 align-items: center;
314 justify-content: center;
315 padding: 0;
316 border: 1px solid var(--shell-line);
317 border-radius: var(--r-ctl);
318 font-size: var(--fs-3);
319 font-weight: 600;
320}
309ul.raillist.wide a { white-space: normal; display: block; } 321ul.raillist.wide a { white-space: normal; display: block; }
310ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } 322ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); }
311ul.raillist.wide .owner { display: block; font-size: var(--fs-0); } 323ul.raillist.wide .owner { display: block; font-size: var(--fs-0); }
@@ -1461,28 +1473,48 @@ svg.icon { vertical-align: -0.125em; }
1461 height: auto; 1473 height: auto;
1462 position: static; 1474 position: static;
1463 flex-direction: row; 1475 flex-direction: row;
1464 align-items: center; 1476 align-items: flex-start;
1465 gap: var(--sp-2); 1477 gap: var(--sp-2);
1466 border-right: 0; 1478 border-right: 0;
1467 border-bottom: 1px solid var(--shell-line); 1479 border-bottom: 1px solid var(--shell-line);
1468 overflow-x: auto; 1480 overflow-x: auto;
1469 } 1481 }
1470 a.brand { padding: var(--sp-2) var(--sp-3); } 1482 a.brand { padding: var(--sp-2) var(--sp-3); min-height: 39px; display: flex; align-items: center; }
1471 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; flex-direction: column; } 1483 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; flex-direction: column; }
1472 .railtop { display: flex; align-items: center; gap: var(--sp-2); overflow-x: auto; } 1484 .railtop { display: flex; align-items: center; gap: var(--sp-2); overflow-x: auto; }
1473 .railtop > * { flex-shrink: 0; } 1485 .railtop > * { flex-shrink: 0; }
1474 form.railsearch { margin: 0 var(--sp-2); } 1486 form.railsearch { margin: 0 var(--sp-2); }
1475 form.railsearch input[type="search"] { min-width: 8rem; } 1487 form.railsearch input[type="search"] { min-width: 8rem; }
1476 /* the strip hides the review queue, reachable from the dashboard, and 1488 /* the strip hides the review queue, reachable from the dashboard, and
1477 shows pinned repositories as a second row of the strip */ 1489 shows pinned repositories as a second, quieter row of the strip */
1478 .railgroup { display: none; } 1490 .railgroup { display: none; }
1479 .railgroup.pinned { 1491 .railgroup.pinned {
1480 display: block; 1492 display: flex;
1493 align-items: center;
1494 gap: var(--sp-2);
1495 width: 100%;
1481 border-top: 1px solid var(--shell-line); 1496 border-top: 1px solid var(--shell-line);
1482 padding: 0 var(--sp-2); 1497 padding: 0 var(--sp-2);
1498 min-height: 32px;
1499 }
1500 .railgroup.pinned .raillabel {
1501 display: block;
1502 margin: 0;
1503 font-size: var(--fs-0);
1504 color: var(--shell-muted);
1505 text-transform: uppercase;
1506 letter-spacing: .08em;
1507 flex: none;
1508 }
1509 .railgroup.pinned ul.raillist {
1510 display: flex;
1511 overflow-x: auto;
1512 margin: 0;
1513 flex: 1;
1514 min-width: 0;
1483 } 1515 }
1484 .railgroup.pinned .raillabel { display: none; } 1516 .railgroup.pinned ul.raillist a { font-size: var(--fs-1); padding: var(--sp-1) var(--sp-2); }
1485 .railgroup.pinned ul.raillist { overflow-x: auto; } 1517 .railgroup.pinned ul.raillist a .owner { display: none; }
1486 ul.raillist { display: flex; margin: 0; } 1518 ul.raillist { display: flex; margin: 0; }
1487 ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); } 1519 ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
1488 ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); } 1520 ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); }
internal/web/templates/dashboard.html +1 −1
@@ -36,7 +36,7 @@
36<aside class="aside"> 36<aside class="aside">
37 <div class="grp"> 37 <div class="grp">
38 <h2>Recent activity</h2> 38 <h2>Recent activity</h2>
39 {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p> 39 {{range .Feed}}<p class="row feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
40 {{else}}<p class="none">No activity yet</p>{{end}} 40 {{else}}<p class="none">No activity yet</p>{{end}}
41 </div> 41 </div>
42</aside> 42</aside>
internal/web/templates/layout.html +1 −1
@@ -25,7 +25,7 @@
25 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> 25 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
26 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li> 26 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li>
27 <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li> 27 <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li>
28 <li><a href="/new">New repository</a></li>{{end}} 28 <li><a class="newrepo" href="/new" aria-label="New repository" title="New repository">+</a></li>{{end}}
29 </ul> 29 </ul>
30 </div> 30 </div>
31 {{with .Rail.Pinned}} 31 {{with .Rail.Pinned}}