detached 0947903

feat(web): recent actions on the summary and visible status marks (#25)

commit
09479031802551efcc45e4ac5c1782372a8df4f3
author
brice · · 2026-10-14 15:58 UTC
committer
bnema · · 2026-10-14 15:58 UTC
parent
dd8597a
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
13 files · +1125 -656
M internal/adapters/kvstore/ci.go +21 -8
@@ -660,19 +660,32 @@ func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, l
660660 if before > 0 {
661661 end = runKey(repo, before)
662662 }
663- // Collect then reverse: the snapshot iterates in key order only.
664- var all []domain.CIRun
663+ // The snapshot iterates in key order only: keep the last limit items in a
664+ // ring and decode only those, so the cost of a page does not grow with the
665+ // decoded history of the repository.
666+ type entry struct {
667+ key []byte
668+ item kv.Item
669+ }
670+ var ring []entry
671+ next := 0
665672 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
666- r, err := decodeRun(item)
673+ if limit <= 0 || len(ring) < limit {
674+ ring = append(ring, entry{key, item})
675+ continue
676+ }
677+ ring[next] = entry{key, item}
678+ next = (next + 1) % limit
679+ }
680+ ordered := append(ring[next:len(ring):len(ring)], ring[:next]...)
681+ all := make([]domain.CIRun, 0, len(ordered))
682+ for i := len(ordered) - 1; i >= 0; i-- {
683+ r, err := decodeRun(ordered[i].item)
667684 if err != nil {
668- return nil, fmt.Errorf("decode %s: %w", key, err)
685+ return nil, fmt.Errorf("decode %s: %w", ordered[i].key, err)
669686 }
670687 all = append(all, r)
671688 }
672- slices.Reverse(all)
673- if limit > 0 && len(all) > limit {
674- all = all[:limit]
675- }
676689 return all, nil
677690 }
678691
@@ -660,19 +660,32 @@ func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, l
660 if before > 0 {660 if before > 0 {
661 end = runKey(repo, before)661 end = runKey(repo, before)
662 }662 }
663- // Collect then reverse: the snapshot iterates in key order only.663+ // The snapshot iterates in key order only: keep the last limit items in a
664- var all []domain.CIRun664+ // ring and decode only those, so the cost of a page does not grow with the
665+ // decoded history of the repository.
666+ type entry struct {
667+ key []byte
668+ item kv.Item
669+ }
670+ var ring []entry
671+ next := 0
665 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {672 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
666- r, err := decodeRun(item)673+ if limit <= 0 || len(ring) < limit {
674+ ring = append(ring, entry{key, item})
675+ continue
676+ }
677+ ring[next] = entry{key, item}
678+ next = (next + 1) % limit
679+ }
680+ ordered := append(ring[next:len(ring):len(ring)], ring[:next]...)
681+ all := make([]domain.CIRun, 0, len(ordered))
682+ for i := len(ordered) - 1; i >= 0; i-- {
683+ r, err := decodeRun(ordered[i].item)
667 if err != nil {684 if err != nil {
668- return nil, fmt.Errorf("decode %s: %w", key, err)685+ return nil, fmt.Errorf("decode %s: %w", ordered[i].key, err)
669 }686 }
670 all = append(all, r)687 all = append(all, r)
671 }688 }
672- slices.Reverse(all)
673- if limit > 0 && len(all) > limit {
674- all = all[:limit]
675- }
676 return all, nil689 return all, nil
677 }690 }
678 691
M internal/adapters/kvstore/ci_test.go +5 -0
@@ -204,6 +204,11 @@ func TestCIListRunsNewestFirstAndBounded(t *testing.T) {
204204 runs, err = ci.ListRuns(ctx, n, 4, 10)
205205 require.NoError(t, err)
206206 assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})
207+ runs, err = ci.ListRuns(ctx, n, 0, 0)
208+ require.NoError(t, err)
209+ require.Len(t, runs, 5)
210+ assert.Equal(t, int64(6), runs[0].ID)
211+ assert.Equal(t, int64(2), runs[4].ID)
207212 ids, err := ci.RunIDs(ctx, n)
208213 require.NoError(t, err)
209214 assert.Len(t, ids, 5)
@@ -204,6 +204,11 @@ func TestCIListRunsNewestFirstAndBounded(t *testing.T) {
204 runs, err = ci.ListRuns(ctx, n, 4, 10)204 runs, err = ci.ListRuns(ctx, n, 4, 10)
205 require.NoError(t, err)205 require.NoError(t, err)
206 assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})206 assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})
207+ runs, err = ci.ListRuns(ctx, n, 0, 0)
208+ require.NoError(t, err)
209+ require.Len(t, runs, 5)
210+ assert.Equal(t, int64(6), runs[0].ID)
211+ assert.Equal(t, int64(2), runs[4].ID)
207 ids, err := ci.RunIDs(ctx, n)212 ids, err := ci.RunIDs(ctx, n)
208 require.NoError(t, err)213 require.NoError(t, err)
209 assert.Len(t, ids, 5)214 assert.Len(t, ids, 5)
M internal/adapters/web/actions.templ +21 -0
@@ -56,6 +56,27 @@ templ RunListRow(r RunRow) {
5656 </tr>
5757 }
5858
59+// RecentActionsBlock is the "Recent actions" section of the summary rail; its
60+// id is the target of the live updates. Without a run it is hidden.
61+templ RecentActionsBlock(a RecentActions) {
62+ <section id="recent-actions" aria-labelledby="r-actions" hidden?={ len(a.Runs) == 0 }>
63+ <h2 id="r-actions">Recent actions <a href={ templ.SafeURL(a.AllHref) }>View all</a></h2>
64+ <ul class="rruns">
65+ for _, r := range a.Runs {
66+ <li>
67+ @CheckIcon(r.Kind, r.State)
68+ <a class="rr-name" href={ templ.SafeURL(r.Href) } title={ r.Workflow + " " + r.Number + " · " + r.State }>{ r.Workflow }</a>
69+ <span class="mute">{ r.Number }</span>
70+ <a class="rr-ref" href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a>
71+ if r.WhenAt != "" {
72+ <time class="dim" datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
73+ }
74+ </li>
75+ }
76+ </ul>
77+ </section>
78+}
79+
5980 templ RunDetail(l Layout, p RunPage) {
6081 @Shell(l, runRail(p)) {
6182 @LiveSlot("run-notice")
@@ -56,6 +56,27 @@ templ RunListRow(r RunRow) {
56 </tr>56 </tr>
57 }57 }
58 58
59+// RecentActionsBlock is the "Recent actions" section of the summary rail; its
60+// id is the target of the live updates. Without a run it is hidden.
61+templ RecentActionsBlock(a RecentActions) {
62+ <section id="recent-actions" aria-labelledby="r-actions" hidden?={ len(a.Runs) == 0 }>
63+ <h2 id="r-actions">Recent actions <a href={ templ.SafeURL(a.AllHref) }>View all</a></h2>
64+ <ul class="rruns">
65+ for _, r := range a.Runs {
66+ <li>
67+ @CheckIcon(r.Kind, r.State)
68+ <a class="rr-name" href={ templ.SafeURL(r.Href) } title={ r.Workflow + " " + r.Number + " · " + r.State }>{ r.Workflow }</a>
69+ <span class="mute">{ r.Number }</span>
70+ <a class="rr-ref" href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a>
71+ if r.WhenAt != "" {
72+ <time class="dim" datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
73+ }
74+ </li>
75+ }
76+ </ul>
77+ </section>
78+}
79+
59 templ RunDetail(l Layout, p RunPage) {80 templ RunDetail(l Layout, p RunPage) {
60 @Shell(l, runRail(p)) {81 @Shell(l, runRail(p)) {
61 @LiveSlot("run-notice")82 @LiveSlot("run-notice")
M internal/adapters/web/actions_templ.go +808 -609 generated file: collapsed
M internal/adapters/web/events.go +2 -1
@@ -53,7 +53,8 @@ import (
5353 // commits-head data: <div id="commits-head">N new commits — reload</div>
5454 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
5555 // something changed, with the link that reloads the page
56-// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57+// <section id="recent-actions">…</section> (the rail of the summary) or
5758 // <div id="run-body">…</div> (one run)
5859 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
5960 // itself is never sent in this stream: the page follows it on its own
@@ -53,7 +53,8 @@ import (
53 // commits-head data: <div id="commits-head">N new commits — reload</div>53 // commits-head data: <div id="commits-head">N new commits — reload</div>
54 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:54 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
55 // something changed, with the link that reloads the page55 // something changed, with the link that reloads the page
56-// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57+// <section id="recent-actions">…</section> (the rail of the summary) or
57 // <div id="run-body">…</div> (one run)58 // <div id="run-body">…</div> (one run)
58 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log59 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59 // itself is never sent in this stream: the page follows it on its own60 // itself is never sent in this stream: the page follows it on its own
M internal/adapters/web/fragments.go +23 -1
@@ -407,7 +407,8 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
407407 }
408408
409409 // runEvent updates the pages of CI that a RunUpdated changes: the row of the
410-// list of runs, or the body of the run page shown. Only a state is sent: the
410+// list of runs, the recent actions of the summary, or the body of the run page
411+// shown. Only a state is sent: the
411412 // events carry no log and no text of the workflow.
412413 func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
413414 if l.s.cfg.Runs == nil {
@@ -423,6 +424,8 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
423424 f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
424425 }
425426 return []fragment{f}, nil
427+ case "summary":
428+ return l.recentActions(ctx)
426429 case "run":
427430 if strconv.FormatInt(ev.Run.ID, 10) != l.n {
428431 return nil, nil
@@ -432,6 +435,25 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
432435 return nil, nil
433436 }
434437
438+// recentActions is the "Recent actions" block of the summary rail, read again
439+// as the principal of the stream: the newest runs change with every event, and
440+// a viewer who cannot read CI any more gets the block hidden and empty.
441+func (l *liveStream) recentActions(ctx context.Context) ([]fragment, error) {
442+ name, err := domain.ParseRepoName(l.repo)
443+ if err != nil {
444+ return nil, nil
445+ }
446+ a, err := l.s.listRecentActions(ctx, name, l.s.cfg.Now())
447+ switch {
448+ case errors.Is(err, domain.ErrNotFound):
449+ a = RecentActions{AllHref: actionsBase(l.repo)}
450+ case err != nil:
451+ return nil, err
452+ }
453+ f, err := renderFragment(ctx, "ci-run", RecentActionsBlock(a))
454+ return []fragment{f}, err
455+}
456+
435457 // jobEvent updates the body of the run page and the steps of the job page.
436458 func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
437459 if l.s.cfg.Runs == nil {
@@ -407,7 +407,8 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
407 }407 }
408 408
409 // runEvent updates the pages of CI that a RunUpdated changes: the row of the409 // runEvent updates the pages of CI that a RunUpdated changes: the row of the
410-// list of runs, or the body of the run page shown. Only a state is sent: the410+// list of runs, the recent actions of the summary, or the body of the run page
411+// shown. Only a state is sent: the
411 // events carry no log and no text of the workflow.412 // events carry no log and no text of the workflow.
412 func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {413 func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
413 if l.s.cfg.Runs == nil {414 if l.s.cfg.Runs == nil {
@@ -423,6 +424,8 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
423 f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)424 f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
424 }425 }
425 return []fragment{f}, nil426 return []fragment{f}, nil
427+ case "summary":
428+ return l.recentActions(ctx)
426 case "run":429 case "run":
427 if strconv.FormatInt(ev.Run.ID, 10) != l.n {430 if strconv.FormatInt(ev.Run.ID, 10) != l.n {
428 return nil, nil431 return nil, nil
@@ -432,6 +435,25 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
432 return nil, nil435 return nil, nil
433 }436 }
434 437
438+// recentActions is the "Recent actions" block of the summary rail, read again
439+// as the principal of the stream: the newest runs change with every event, and
440+// a viewer who cannot read CI any more gets the block hidden and empty.
441+func (l *liveStream) recentActions(ctx context.Context) ([]fragment, error) {
442+ name, err := domain.ParseRepoName(l.repo)
443+ if err != nil {
444+ return nil, nil
445+ }
446+ a, err := l.s.listRecentActions(ctx, name, l.s.cfg.Now())
447+ switch {
448+ case errors.Is(err, domain.ErrNotFound):
449+ a = RecentActions{AllHref: actionsBase(l.repo)}
450+ case err != nil:
451+ return nil, err
452+ }
453+ f, err := renderFragment(ctx, "ci-run", RecentActionsBlock(a))
454+ return []fragment{f}, err
455+}
456+
435 // jobEvent updates the body of the run page and the steps of the job page.457 // jobEvent updates the body of the run page and the steps of the job page.
436 func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {458 func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
437 if l.s.cfg.Runs == nil {459 if l.s.cfg.Runs == nil {
M internal/adapters/web/pages.templ +3 -0
@@ -80,6 +80,9 @@ templ summaryRail(p SummaryPage) {
8080 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)
8181 </section>
8282 }
83+ if p.Actions != nil {
84+ @RecentActionsBlock(*p.Actions)
85+ }
8386 if len(p.Outline) > 0 && p.Readme != nil {
8487 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)
8588 </section>
@@ -80,6 +80,9 @@ templ summaryRail(p SummaryPage) {
80 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)80 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)
81 </section>81 </section>
82 }82 }
83+ if p.Actions != nil {
84+ @RecentActionsBlock(*p.Actions)
85+ }
83 if len(p.Outline) > 0 && p.Readme != nil {86 if len(p.Outline) > 0 && p.Readme != nil {
84 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)87 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)
85 </section>88 </section>
M internal/adapters/web/pages_templ.go +42 -36 generated file: collapsed
M internal/adapters/web/static/css/65-ci.css +19 -1
@@ -15,9 +15,27 @@
1515 @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
1616
1717 .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }
18+ /* The lists are table-layout: fixed, where a percentage is only a sliver:
19+ the check column holds the mark, its count and the padding of the cells;
20+ the refs list has the mark next to the sha. */
1821 .c-ck { width: 1%; white-space: nowrap; }
22+ .list .c-ck { width: 92px; }
23+ .rail .list.commits .c-ck { width: 76px; }
24+ .list.refs .c-sha { width: 150px; }
1925
20- .runs .c-st { width: 1%; }
26+ /* the table is fixed: the status column is the icon, its margin and the
27+ padding of the cells (a percentage would leave a sliver) */
28+ .runs .c-st { width: calc(14px + var(--s1) + 2 * var(--s3)); }
29+ /* the recent runs of the summary rail */
30+ .rruns {
31+ display: grid; padding: 0; list-style: none; font-size: var(--fs-s);
32+ & li { display: flex; align-items: center; gap: var(--s1); min-width: 0; padding: 2px 0; }
33+ & .ck { margin-right: var(--s1); }
34+ & .rr-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); text-decoration: none; }
35+ & .rr-name:hover { text-decoration: underline; }
36+ & .rr-ref { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: var(--s1); font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
37+ & time { flex: none; margin-left: auto; padding-left: var(--s2); white-space: nowrap; }
38+ }
2139 .runs .c-dur, .runs .c-who { white-space: nowrap; }
2240 .runs tr[data-new] { background: var(--bg-raised); }
2341
@@ -15,9 +15,27 @@
15 @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }15 @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
16 16
17 .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }17 .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }
18+ /* The lists are table-layout: fixed, where a percentage is only a sliver:
19+ the check column holds the mark, its count and the padding of the cells;
20+ the refs list has the mark next to the sha. */
18 .c-ck { width: 1%; white-space: nowrap; }21 .c-ck { width: 1%; white-space: nowrap; }
22+ .list .c-ck { width: 92px; }
23+ .rail .list.commits .c-ck { width: 76px; }
24+ .list.refs .c-sha { width: 150px; }
19 25
20- .runs .c-st { width: 1%; }26+ /* the table is fixed: the status column is the icon, its margin and the
27+ padding of the cells (a percentage would leave a sliver) */
28+ .runs .c-st { width: calc(14px + var(--s1) + 2 * var(--s3)); }
29+ /* the recent runs of the summary rail */
30+ .rruns {
31+ display: grid; padding: 0; list-style: none; font-size: var(--fs-s);
32+ & li { display: flex; align-items: center; gap: var(--s1); min-width: 0; padding: 2px 0; }
33+ & .ck { margin-right: var(--s1); }
34+ & .rr-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); text-decoration: none; }
35+ & .rr-name:hover { text-decoration: underline; }
36+ & .rr-ref { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: var(--s1); font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
37+ & time { flex: none; margin-left: auto; padding-left: var(--s2); white-space: nowrap; }
38+ }
21 .runs .c-dur, .runs .c-who { white-space: nowrap; }39 .runs .c-dur, .runs .c-who { white-space: nowrap; }
22 .runs tr[data-new] { background: var(--bg-raised); }40 .runs tr[data-new] { background: var(--bg-raised); }
23 41
M internal/adapters/web/summary.go +37 -0
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89 "html"
910 "html/template"
@@ -26,6 +27,9 @@ const maxReadmeBytes = 512 << 10
2627 // recentCommits is how many commits the rail of the summary lists.
2728 const recentCommits = 5
2829
30+// recentRuns is how many runs the rail of the summary lists.
31+const recentRuns = 5
32+
2933 // readmeNames are the README file names in order of preference (compared
3034 // lowercase): Markdown first.
3135 var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
@@ -63,6 +67,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
6367
6468 p.Facts = s.facts(d, now)
6569 p.Latest = s.latest(ctx, d)
70+ p.Actions = s.recentActions(ctx, d.repo.Name, now)
6671
6772 if rd, err := s.readme(ctx, d, ref); err != nil {
6873 return nil, 0, err
@@ -232,3 +237,35 @@ func humanSize(n int64) string {
232237 }
233238 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
234239 }
240+
241+// recentActions is the newest runs for the rail, as the caller of the request
242+// sees them (the permission of the actions page). It is nil when the server
243+// runs no CI or the caller cannot read it; a failure of the read is logged and
244+// the page goes on without, since the code does not depend on CI.
245+func (s *Server) recentActions(ctx context.Context, repo domain.RepoName, now time.Time) *RecentActions {
246+ if s.cfg.Runs == nil {
247+ return nil
248+ }
249+ a, err := s.listRecentActions(ctx, repo, now)
250+ if err != nil {
251+ if !errors.Is(err, domain.ErrNotFound) {
252+ log := zerowrap.FromCtx(ctx)
253+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the recent runs")
254+ }
255+ return nil
256+ }
257+ return &a
258+}
259+
260+// listRecentActions reads the newest runs of a repository.
261+func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, now time.Time) (RecentActions, error) {
262+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), repo, ports.ListRunsParams{PageSize: recentRuns})
263+ if err != nil {
264+ return RecentActions{}, err
265+ }
266+ a := RecentActions{AllHref: actionsBase(repo.String())}
267+ for _, run := range page.Runs {
268+ a.Runs = append(a.Runs, s.runRow(repo.String(), run, now))
269+ }
270+ return a, nil
271+}
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "fmt"8 "fmt"
8 "html"9 "html"
9 "html/template"10 "html/template"
@@ -26,6 +27,9 @@ const maxReadmeBytes = 512 << 10
26 // recentCommits is how many commits the rail of the summary lists.27 // recentCommits is how many commits the rail of the summary lists.
27 const recentCommits = 528 const recentCommits = 5
28 29
30+// recentRuns is how many runs the rail of the summary lists.
31+const recentRuns = 5
32+
29 // readmeNames are the README file names in order of preference (compared33 // readmeNames are the README file names in order of preference (compared
30 // lowercase): Markdown first.34 // lowercase): Markdown first.
31 var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}35 var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
@@ -63,6 +67,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
63 67
64 p.Facts = s.facts(d, now)68 p.Facts = s.facts(d, now)
65 p.Latest = s.latest(ctx, d)69 p.Latest = s.latest(ctx, d)
70+ p.Actions = s.recentActions(ctx, d.repo.Name, now)
66 71
67 if rd, err := s.readme(ctx, d, ref); err != nil {72 if rd, err := s.readme(ctx, d, ref); err != nil {
68 return nil, 0, err73 return nil, 0, err
@@ -232,3 +237,35 @@ func humanSize(n int64) string {
232 }237 }
233 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))238 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
234 }239 }
240+
241+// recentActions is the newest runs for the rail, as the caller of the request
242+// sees them (the permission of the actions page). It is nil when the server
243+// runs no CI or the caller cannot read it; a failure of the read is logged and
244+// the page goes on without, since the code does not depend on CI.
245+func (s *Server) recentActions(ctx context.Context, repo domain.RepoName, now time.Time) *RecentActions {
246+ if s.cfg.Runs == nil {
247+ return nil
248+ }
249+ a, err := s.listRecentActions(ctx, repo, now)
250+ if err != nil {
251+ if !errors.Is(err, domain.ErrNotFound) {
252+ log := zerowrap.FromCtx(ctx)
253+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the recent runs")
254+ }
255+ return nil
256+ }
257+ return &a
258+}
259+
260+// listRecentActions reads the newest runs of a repository.
261+func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, now time.Time) (RecentActions, error) {
262+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), repo, ports.ListRunsParams{PageSize: recentRuns})
263+ if err != nil {
264+ return RecentActions{}, err
265+ }
266+ a := RecentActions{AllHref: actionsBase(repo.String())}
267+ for _, run := range page.Runs {
268+ a.Runs = append(a.Runs, s.runRow(repo.String(), run, now))
269+ }
270+ return a, nil
271+}
A internal/adapters/web/summary_actions_test.go +127 -0
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func testRuns(repo domain.RepoName, n int) []domain.CIRun {
22+ var runs []domain.CIRun
23+ for i := n; i >= 1; i-- {
24+ runs = append(runs, domain.CIRun{
25+ Repo: repo, ID: int64(i), Number: int64(i), Attempt: 1, WorkflowName: "ci", Ref: "refs/heads/main",
26+ Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess,
27+ CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
28+ })
29+ }
30+ return runs
31+}
32+
33+func servingRuns(t *testing.T, repo domain.RepoName, runs []domain.CIRun, err error) (*Server, *mocks.RunService) {
34+ t.Helper()
35+ m := mocks.NewRunService(t)
36+ m.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, ports.ListRunsParams{PageSize: recentRuns}).
37+ Return(ports.RunPage{Runs: runs}, err).Maybe()
38+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: mocks.NewBrowseService(t), Runs: m}), m
39+}
40+
41+func renderRail(t *testing.T, p SummaryPage) string {
42+ t.Helper()
43+ var b bytes.Buffer
44+ require.NoError(t, summaryRail(p).Render(t.Context(), &b))
45+ return b.String()
46+}
47+
48+func TestSummaryRailRecentActions(t *testing.T) {
49+ t.Parallel()
50+ repo := repoName(t, "proj")
51+ srv, _ := servingRuns(t, repo, testRuns(repo, 5), nil)
52+
53+ p := SummaryPage{Actions: srv.recentActions(t.Context(), repo, time.Now())}
54+ require.NotNil(t, p.Actions)
55+ out := renderRail(t, p)
56+ assert.Contains(t, out, `id="recent-actions"`)
57+ assert.Contains(t, out, "Recent actions")
58+ assert.Contains(t, out, `href="/proj/actions"`, "view all")
59+ assert.Equal(t, 5, strings.Count(out, `class="rr-name"`))
60+ assert.Contains(t, out, `href="/proj/actions/runs/5"`)
61+ assert.Contains(t, out, `aria-label="success"`, "the status icon")
62+ assert.Contains(t, out, `href="/proj/tree/main"`, "the ref")
63+ assert.Contains(t, out, "#5")
64+ assert.NotContains(t, out, `id="recent-actions" aria-labelledby="r-actions" hidden`)
65+}
66+
67+func TestSummaryRailRecentActionsHidden(t *testing.T) {
68+ t.Parallel()
69+ repo := repoName(t, "proj")
70+
71+ // The viewer cannot read CI: no block at all, not even to receive events.
72+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
73+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
74+ assert.NotContains(t, renderRail(t, SummaryPage{}), "recent-actions")
75+
76+ // A failure of the read does not break the page.
77+ srv, _ = servingRuns(t, repo, nil, errors.New("boom"))
78+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
79+
80+ // No CI on this server.
81+ noCI := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
82+ assert.Nil(t, noCI.recentActions(t.Context(), repo, time.Now()))
83+
84+ // No run yet: the block is there for the first run, but hidden.
85+ srv, _ = servingRuns(t, repo, nil, nil)
86+ a := srv.recentActions(t.Context(), repo, time.Now())
87+ require.NotNil(t, a)
88+ assert.Contains(t, renderRail(t, SummaryPage{Actions: a}), `id="recent-actions" aria-labelledby="r-actions" hidden`)
89+}
90+
91+func TestRunUpdatedReplacesRecentActionsOnSummary(t *testing.T) {
92+ t.Parallel()
93+ repo := repoName(t, "proj")
94+ runs := testRuns(repo, 3)
95+ runs[0].Status, runs[0].Conclusion = domain.CIInProgress, ""
96+ srv, _ := servingRuns(t, repo, runs, nil)
97+
98+ ev := ports.WatchEvent{Event: domain.RunUpdated{Run: runs[0]}}
99+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
100+ got, err := ls.render(t.Context(), ev)
101+ require.NoError(t, err)
102+ require.Len(t, got, 1)
103+ assert.Equal(t, "ci-run", got[0].event)
104+ assert.True(t, strings.HasPrefix(got[0].html, `<section id="recent-actions"`), got[0].html)
105+ assert.Equal(t, 3, strings.Count(got[0].html, `class="rr-name"`))
106+ assert.Contains(t, got[0].html, `aria-label="running"`)
107+
108+ // The other pages do not get the block.
109+ for _, view := range []string{"refs", "commits", "mrs"} {
110+ got, err = (&liveStream{s: srv, repo: "proj", view: view}).render(t.Context(), ev)
111+ require.NoError(t, err, view)
112+ assert.Empty(t, got, view)
113+ }
114+}
115+
116+func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
117+ t.Parallel()
118+ repo := repoName(t, "proj")
119+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
120+
121+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
122+ got, err := ls.render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: testRuns(repo, 1)[0]}})
123+ require.NoError(t, err)
124+ require.Len(t, got, 1)
125+ assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126+ assert.NotContains(t, got[0].html, "rr-name")
127+}
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func testRuns(repo domain.RepoName, n int) []domain.CIRun {
22+ var runs []domain.CIRun
23+ for i := n; i >= 1; i-- {
24+ runs = append(runs, domain.CIRun{
25+ Repo: repo, ID: int64(i), Number: int64(i), Attempt: 1, WorkflowName: "ci", Ref: "refs/heads/main",
26+ Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess,
27+ CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
28+ })
29+ }
30+ return runs
31+}
32+
33+func servingRuns(t *testing.T, repo domain.RepoName, runs []domain.CIRun, err error) (*Server, *mocks.RunService) {
34+ t.Helper()
35+ m := mocks.NewRunService(t)
36+ m.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, ports.ListRunsParams{PageSize: recentRuns}).
37+ Return(ports.RunPage{Runs: runs}, err).Maybe()
38+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: mocks.NewBrowseService(t), Runs: m}), m
39+}
40+
41+func renderRail(t *testing.T, p SummaryPage) string {
42+ t.Helper()
43+ var b bytes.Buffer
44+ require.NoError(t, summaryRail(p).Render(t.Context(), &b))
45+ return b.String()
46+}
47+
48+func TestSummaryRailRecentActions(t *testing.T) {
49+ t.Parallel()
50+ repo := repoName(t, "proj")
51+ srv, _ := servingRuns(t, repo, testRuns(repo, 5), nil)
52+
53+ p := SummaryPage{Actions: srv.recentActions(t.Context(), repo, time.Now())}
54+ require.NotNil(t, p.Actions)
55+ out := renderRail(t, p)
56+ assert.Contains(t, out, `id="recent-actions"`)
57+ assert.Contains(t, out, "Recent actions")
58+ assert.Contains(t, out, `href="/proj/actions"`, "view all")
59+ assert.Equal(t, 5, strings.Count(out, `class="rr-name"`))
60+ assert.Contains(t, out, `href="/proj/actions/runs/5"`)
61+ assert.Contains(t, out, `aria-label="success"`, "the status icon")
62+ assert.Contains(t, out, `href="/proj/tree/main"`, "the ref")
63+ assert.Contains(t, out, "#5")
64+ assert.NotContains(t, out, `id="recent-actions" aria-labelledby="r-actions" hidden`)
65+}
66+
67+func TestSummaryRailRecentActionsHidden(t *testing.T) {
68+ t.Parallel()
69+ repo := repoName(t, "proj")
70+
71+ // The viewer cannot read CI: no block at all, not even to receive events.
72+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
73+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
74+ assert.NotContains(t, renderRail(t, SummaryPage{}), "recent-actions")
75+
76+ // A failure of the read does not break the page.
77+ srv, _ = servingRuns(t, repo, nil, errors.New("boom"))
78+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
79+
80+ // No CI on this server.
81+ noCI := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
82+ assert.Nil(t, noCI.recentActions(t.Context(), repo, time.Now()))
83+
84+ // No run yet: the block is there for the first run, but hidden.
85+ srv, _ = servingRuns(t, repo, nil, nil)
86+ a := srv.recentActions(t.Context(), repo, time.Now())
87+ require.NotNil(t, a)
88+ assert.Contains(t, renderRail(t, SummaryPage{Actions: a}), `id="recent-actions" aria-labelledby="r-actions" hidden`)
89+}
90+
91+func TestRunUpdatedReplacesRecentActionsOnSummary(t *testing.T) {
92+ t.Parallel()
93+ repo := repoName(t, "proj")
94+ runs := testRuns(repo, 3)
95+ runs[0].Status, runs[0].Conclusion = domain.CIInProgress, ""
96+ srv, _ := servingRuns(t, repo, runs, nil)
97+
98+ ev := ports.WatchEvent{Event: domain.RunUpdated{Run: runs[0]}}
99+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
100+ got, err := ls.render(t.Context(), ev)
101+ require.NoError(t, err)
102+ require.Len(t, got, 1)
103+ assert.Equal(t, "ci-run", got[0].event)
104+ assert.True(t, strings.HasPrefix(got[0].html, `<section id="recent-actions"`), got[0].html)
105+ assert.Equal(t, 3, strings.Count(got[0].html, `class="rr-name"`))
106+ assert.Contains(t, got[0].html, `aria-label="running"`)
107+
108+ // The other pages do not get the block.
109+ for _, view := range []string{"refs", "commits", "mrs"} {
110+ got, err = (&liveStream{s: srv, repo: "proj", view: view}).render(t.Context(), ev)
111+ require.NoError(t, err, view)
112+ assert.Empty(t, got, view)
113+ }
114+}
115+
116+func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
117+ t.Parallel()
118+ repo := repoName(t, "proj")
119+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
120+
121+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
122+ got, err := ls.render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: testRuns(repo, 1)[0]}})
123+ require.NoError(t, err)
124+ require.Len(t, got, 1)
125+ assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126+ assert.NotContains(t, got[0].html, "rr-name")
127+}
M internal/adapters/web/view.go +11 -0
@@ -153,6 +153,17 @@ type SummaryPage struct {
153153 AllHref string
154154 // Latest is the newest tag, nil when the repository has none.
155155 Latest *LatestTag
156+ // Actions is the recent runs of the rail, nil when the viewer cannot read
157+ // CI (or the server runs none).
158+ Actions *RecentActions
159+}
160+
161+// RecentActions is the "Recent actions" block of the summary rail: the newest
162+// runs. With no run the block is in the page but hidden, so that the first
163+// run reaches it live.
164+type RecentActions struct {
165+ Runs []RunRow
166+ AllHref string
156167 }
157168
158169 // LatestTag is the newest tag of a repository, shown in the summary rail.
@@ -153,6 +153,17 @@ type SummaryPage struct {
153 AllHref string153 AllHref string
154 // Latest is the newest tag, nil when the repository has none.154 // Latest is the newest tag, nil when the repository has none.
155 Latest *LatestTag155 Latest *LatestTag
156+ // Actions is the recent runs of the rail, nil when the viewer cannot read
157+ // CI (or the server runs none).
158+ Actions *RecentActions
159+}
160+
161+// RecentActions is the "Recent actions" block of the summary rail: the newest
162+// runs. With no run the block is in the page but hidden, so that the first
163+// run reaches it live.
164+type RecentActions struct {
165+ Runs []RunRow
166+ AllHref string
156 }167 }
157 168
158 // LatestTag is the newest tag of a repository, shown in the summary rail.169 // LatestTag is the newest tag of a repository, shown in the summary rail.
M internal/app/testdata/web/summary.html +6 -0
@@ -200,6 +200,12 @@
200200 </tbody>
201201 </table>
202202 </section>
203+<section id="recent-actions" aria-labelledby="r-actions" hidden>
204+<h2 id="r-actions">Recent actions <a href="/demo/actions">View all</a>
205+</h2>
206+<ul class="rruns">
207+</ul>
208+</section>
203209 <section aria-labelledby="r-outline">
204210 <h2 id="r-outline">On this page</h2>
205211 <nav class="outline" aria-label="Outline">
@@ -200,6 +200,12 @@
200 </tbody>200 </tbody>
201 </table>201 </table>
202 </section>202 </section>
203+<section id="recent-actions" aria-labelledby="r-actions" hidden>
204+<h2 id="r-actions">Recent actions <a href="/demo/actions">View all</a>
205+</h2>
206+<ul class="rruns">
207+</ul>
208+</section>
203 <section aria-labelledby="r-outline">209 <section aria-labelledby="r-outline">
204 <h2 id="r-outline">On this page</h2>210 <h2 id="r-outline">On this page</h2>
205 <nav class="outline" aria-label="Outline">211 <nav class="outline" aria-label="Outline">

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close