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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 | |||
| 660 | 660 | if before > 0 { | |
| 661 | 661 | end = runKey(repo, before) | |
| 662 | 662 | } | |
| 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 | |
| 665 | 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 | 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 | 687 | all = append(all, r) | |
| 671 | 688 | } | |
| 672 | - | slices.Reverse(all) | |
| 673 | - | if limit > 0 && len(all) > limit { | |
| 674 | - | all = all[:limit] | |
| 675 | - | } | |
| 676 | 689 | return all, nil | |
| 677 | 690 | } | |
| 678 | 691 | ||
| @@ -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.CIRun | 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 | |||
| 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, nil | 689 | 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) { | |||
| 204 | 204 | runs, err = ci.ListRuns(ctx, n, 4, 10) | |
| 205 | 205 | require.NoError(t, err) | |
| 206 | 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 | 212 | ids, err := ci.RunIDs(ctx, n) | |
| 208 | 213 | require.NoError(t, err) | |
| 209 | 214 | 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) { | |||
| 56 | 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 | 80 | templ RunDetail(l Layout, p RunPage) { | |
| 60 | 81 | @Shell(l, runRail(p)) { | |
| 61 | 82 | @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 ( | |||
| 53 | 53 | // commits-head data: <div id="commits-head">N new commits — reload</div> | |
| 54 | 54 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages: | |
| 55 | 55 | // 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 | |
| 57 | 58 | // <div id="run-body">…</div> (one run) | |
| 58 | 59 | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | |
| 59 | 60 | // 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 page | 55 | // 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 | |||
| 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 log | 59 | // 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 own | 60 | // 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, | |||
| 407 | 407 | } | |
| 408 | 408 | ||
| 409 | 409 | // 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 | |
| 411 | 412 | // events carry no log and no text of the workflow. | |
| 412 | 413 | func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) { | |
| 413 | 414 | if l.s.cfg.Runs == nil { | |
| @@ -423,6 +424,8 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag | |||
| 423 | 424 | f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1) | |
| 424 | 425 | } | |
| 425 | 426 | return []fragment{f}, nil | |
| 427 | + | case "summary": | |
| 428 | + | return l.recentActions(ctx) | |
| 426 | 429 | case "run": | |
| 427 | 430 | if strconv.FormatInt(ev.Run.ID, 10) != l.n { | |
| 428 | 431 | return nil, nil | |
| @@ -432,6 +435,25 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag | |||
| 432 | 435 | 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 | 457 | // jobEvent updates the body of the run page and the steps of the job page. | |
| 436 | 458 | func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) { | |
| 437 | 459 | 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 the | 409 | // 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 | |||
| 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}, nil | 426 | 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, nil | 431 | return nil, nil | ||
| @@ -432,6 +435,25 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag | |||||
| 432 | return nil, nil | 435 | 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) { | |||
| 80 | 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 | 81 | </section> | |
| 82 | 82 | } | |
| 83 | + | if p.Actions != nil { | |
| 84 | + | @RecentActionsBlock(*p.Actions) | |
| 85 | + | } | |
| 83 | 86 | if len(p.Outline) > 0 && p.Readme != nil { | |
| 84 | 87 | <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline) | |
| 85 | 88 | </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 @@ | |||
| 15 | 15 | @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } } | |
| 16 | 16 | ||
| 17 | 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 | 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 | 39 | .runs .c-dur, .runs .c-who { white-space: nowrap; } | |
| 22 | 40 | .runs tr[data-new] { background: var(--bg-raised); } | |
| 23 | 41 | ||
| @@ -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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "html" | |
| 9 | 10 | "html/template" | |
| @@ -26,6 +27,9 @@ const maxReadmeBytes = 512 << 10 | |||
| 26 | 27 | // recentCommits is how many commits the rail of the summary lists. | |
| 27 | 28 | const recentCommits = 5 | |
| 28 | 29 | ||
| 30 | + | // recentRuns is how many runs the rail of the summary lists. | |
| 31 | + | const recentRuns = 5 | |
| 32 | + | ||
| 29 | 33 | // readmeNames are the README file names in order of preference (compared | |
| 30 | 34 | // lowercase): Markdown first. | |
| 31 | 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 | 68 | p.Facts = s.facts(d, now) | |
| 65 | 69 | p.Latest = s.latest(ctx, d) | |
| 70 | + | p.Actions = s.recentActions(ctx, d.repo.Name, now) | |
| 66 | 71 | ||
| 67 | 72 | if rd, err := s.readme(ctx, d, ref); err != nil { | |
| 68 | 73 | return nil, 0, err | |
| @@ -232,3 +237,35 @@ func humanSize(n int64) string { | |||
| 232 | 237 | } | |
| 233 | 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 | + | } | |
| @@ -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 = 5 | 28 | 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 (compared | 33 | // 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, err | 73 | 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 { | |||
| 153 | 153 | AllHref string | |
| 154 | 154 | // Latest is the newest tag, nil when the repository has none. | |
| 155 | 155 | 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 | 169 | // LatestTag is the newest tag of a repository, shown in the summary rail. | |
| @@ -153,6 +153,17 @@ type SummaryPage struct { | |||||
| 153 | AllHref string | 153 | 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 *LatestTag | 155 | 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 @@ | |||
| 200 | 200 | </tbody> | |
| 201 | 201 | </table> | |
| 202 | 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 | 209 | <section aria-labelledby="r-outline"> | |
| 204 | 210 | <h2 id="r-outline">On this page</h2> | |
| 205 | 211 | <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"> | ||