tenseleyflow/shithub / f9f7ba3

Browse files

repo/actions: render workflow run list

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f9f7ba396a09c041f56f5fbd5d87104c74311b6e
Parents
2f1478a
Tree
693a684

3 changed files

StatusFile+-
M internal/web/handlers/repo/actions.go 462 50
M internal/web/static/css/shithub.css 41 1
M internal/web/templates/repo/actions.html 54 27
internal/web/handlers/repo/actions.gomodified
@@ -6,20 +6,81 @@ import (
6
 	"errors"
6
 	"errors"
7
 	"html/template"
7
 	"html/template"
8
 	"net/http"
8
 	"net/http"
9
+	"net/url"
10
+	"path"
9
 	"strconv"
11
 	"strconv"
12
+	"strings"
10
 	"time"
13
 	"time"
11
 
14
 
12
 	"github.com/go-chi/chi/v5"
15
 	"github.com/go-chi/chi/v5"
13
 	"github.com/jackc/pgx/v5"
16
 	"github.com/jackc/pgx/v5"
17
+	"github.com/jackc/pgx/v5/pgtype"
14
 
18
 
19
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
20
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
21
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
17
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
22
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
 )
23
 )
19
 
24
 
25
+const actionsRunsPageSize = int32(20)
26
+
20
 type actionsWorkflowView struct {
27
 type actionsWorkflowView struct {
21
-	Name  string
28
+	File   string
22
-	Count int
29
+	Name   string
30
+	Count  int64
31
+	Href   string
32
+	Active bool
33
+}
34
+
35
+type actionsListRunView struct {
36
+	ID            int64
37
+	RunIndex      int64
38
+	WorkflowFile  string
39
+	WorkflowName  string
40
+	Title         string
41
+	HeadSha       string
42
+	HeadShaShort  string
43
+	HeadRef       string
44
+	Event         string
45
+	EventLabel    string
46
+	ActorUsername string
47
+	StateText     string
48
+	StateClass    string
49
+	StateIcon     string
50
+	CreatedAt     time.Time
51
+	UpdatedAt     time.Time
52
+	Duration      string
53
+	Href          string
54
+}
55
+
56
+type actionsListFilters struct {
57
+	Workflow   string
58
+	Branch     string
59
+	Event      string
60
+	Status     string
61
+	Conclusion string
62
+	Actor      string
63
+	Page       int32
64
+	HasAny     bool
65
+}
66
+
67
+type actionsFilterOption struct {
68
+	Value    string
69
+	Label    string
70
+	Selected bool
71
+}
72
+
73
+type actionsPaginationView struct {
74
+	Page       int32
75
+	PageSize   int32
76
+	Total      int64
77
+	Start      int64
78
+	End        int64
79
+	HasPrev    bool
80
+	HasNext    bool
81
+	PrevHref   string
82
+	NextHref   string
83
+	ResultText string
23
 }
84
 }
24
 
85
 
25
 type actionsSuiteView struct {
86
 type actionsSuiteView struct {
@@ -60,48 +121,417 @@ func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
60
 	if !ok {
121
 	if !ok {
61
 		return
122
 		return
62
 	}
123
 	}
63
-	suiteRows, err := h.cq.ListCheckSuitesForRepo(r.Context(), h.d.Pool, checksdb.ListCheckSuitesForRepoParams{
124
+
64
-		RepoID: row.ID,
125
+	filters := actionsListFiltersFromRequest(r)
65
-		Limit:  50,
126
+	q := actionsdb.New()
66
-		Offset: 0,
127
+	params := workflowRunListParams(row.ID, filters)
67
-	})
128
+	params.PageLimit = actionsRunsPageSize
129
+	params.PageOffset = (filters.Page - 1) * actionsRunsPageSize
130
+
131
+	runs, err := q.ListWorkflowRunsForRepo(r.Context(), h.d.Pool, params)
68
 	if err != nil {
132
 	if err != nil {
69
-		h.d.Logger.WarnContext(r.Context(), "repo actions: list suites", "error", err)
133
+		h.d.Logger.WarnContext(r.Context(), "repo actions: list workflow runs", "repo_id", row.ID, "error", err)
70
 		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
134
 		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
71
 		return
135
 		return
72
 	}
136
 	}
73
-
137
+	filteredCount, err := q.CountWorkflowRunsForRepo(r.Context(), h.d.Pool, workflowRunCountParams(row.ID, filters))
74
-	suites := make([]actionsSuiteView, 0, len(suiteRows))
138
+	if err != nil {
75
-	workflowCounts := map[string]int{}
139
+		h.d.Logger.WarnContext(r.Context(), "repo actions: count workflow runs", "repo_id", row.ID, "error", err)
76
-	workflowOrder := []string{}
140
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
77
-	for _, suite := range suiteRows {
141
+		return
78
-		runs, err := h.cq.ListCheckRunsBySuite(r.Context(), h.d.Pool, suite.ID)
142
+	}
79
-		if err != nil {
143
+	workflowRows, err := q.ListWorkflowRunWorkflowsForRepo(r.Context(), h.d.Pool, row.ID)
80
-			h.d.Logger.WarnContext(r.Context(), "repo actions: list runs", "suite_id", suite.ID, "error", err)
144
+	if err != nil {
81
-			continue
145
+		h.d.Logger.WarnContext(r.Context(), "repo actions: list workflows", "repo_id", row.ID, "error", err)
82
-		}
146
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
83
-		if _, ok := workflowCounts[suite.AppSlug]; !ok {
147
+		return
84
-			workflowOrder = append(workflowOrder, suite.AppSlug)
85
-		}
86
-		workflowCounts[suite.AppSlug]++
87
-		suites = append(suites, actionsSuiteViewFromListRow(suite, runs))
88
 	}
148
 	}
89
 
149
 
90
-	workflows := make([]actionsWorkflowView, 0, len(workflowOrder))
150
+	basePath := "/" + owner.Username + "/" + row.Name + "/actions"
91
-	for _, name := range workflowOrder {
151
+	workflows, allRunCount, activeWorkflowName := actionsWorkflowViews(workflowRows, filters, basePath)
92
-		workflows = append(workflows, actionsWorkflowView{Name: name, Count: workflowCounts[name]})
152
+	runViews := make([]actionsListRunView, 0, len(runs))
153
+	now := time.Now()
154
+	for _, run := range runs {
155
+		runViews = append(runViews, actionsListRunViewFromRow(run, owner.Username, row.Name, now))
93
 	}
156
 	}
94
 
157
 
95
 	data := h.repoHeaderData(r, row, owner.Username, "actions")
158
 	data := h.repoHeaderData(r, row, owner.Username, "actions")
96
 	data["Title"] = "Actions · " + row.Name
159
 	data["Title"] = "Actions · " + row.Name
97
-	data["Suites"] = suites
160
+	data["Runs"] = runViews
98
 	data["Workflows"] = workflows
161
 	data["Workflows"] = workflows
99
-	data["RunCount"] = len(suites)
162
+	data["RunCount"] = allRunCount
163
+	data["FilteredRunCount"] = filteredCount
164
+	data["ActiveWorkflowName"] = activeWorkflowName
165
+	data["Filters"] = filters
166
+	data["EventOptions"] = actionsEventOptions(filters.Event)
167
+	data["StatusOptions"] = actionsStatusOptions(filters.Status)
168
+	data["ConclusionOptions"] = actionsConclusionOptions(filters.Conclusion)
169
+	data["Pagination"] = actionsPagination(basePath, filters, filteredCount, int64(len(runViews)))
100
 	if err := h.d.Render.RenderPage(w, r, "repo/actions", data); err != nil {
170
 	if err := h.d.Render.RenderPage(w, r, "repo/actions", data); err != nil {
101
 		h.d.Logger.ErrorContext(r.Context(), "repo actions render", "error", err)
171
 		h.d.Logger.ErrorContext(r.Context(), "repo actions render", "error", err)
102
 	}
172
 	}
103
 }
173
 }
104
 
174
 
175
+func actionsListFiltersFromRequest(r *http.Request) actionsListFilters {
176
+	q := r.URL.Query()
177
+	f := actionsListFilters{
178
+		Workflow:   trimFilter(q.Get("workflow"), 256),
179
+		Branch:     trimFilter(q.Get("branch"), 256),
180
+		Event:      validWorkflowRunEvent(q.Get("event")),
181
+		Status:     validWorkflowRunStatus(q.Get("status")),
182
+		Conclusion: validWorkflowRunConclusion(q.Get("conclusion")),
183
+		Actor:      trimFilter(q.Get("actor"), 39),
184
+		Page:       parseActionsPage(q.Get("page")),
185
+	}
186
+	f.HasAny = f.Workflow != "" || f.Branch != "" || f.Event != "" || f.Status != "" || f.Conclusion != "" || f.Actor != ""
187
+	return f
188
+}
189
+
190
+func trimFilter(v string, max int) string {
191
+	v = strings.TrimSpace(v)
192
+	if len(v) > max {
193
+		return v[:max]
194
+	}
195
+	return v
196
+}
197
+
198
+func parseActionsPage(v string) int32 {
199
+	page, err := strconv.ParseInt(strings.TrimSpace(v), 10, 32)
200
+	if err != nil || page < 1 {
201
+		return 1
202
+	}
203
+	if page > 100000 {
204
+		return 100000
205
+	}
206
+	return int32(page)
207
+}
208
+
209
+func workflowRunListParams(repoID int64, filters actionsListFilters) actionsdb.ListWorkflowRunsForRepoParams {
210
+	return actionsdb.ListWorkflowRunsForRepoParams{
211
+		RepoID:        repoID,
212
+		WorkflowFile:  nullableText(filters.Workflow),
213
+		HeadRef:       nullableText(filters.Branch),
214
+		Event:         nullableWorkflowRunEvent(filters.Event),
215
+		Status:        nullableWorkflowRunStatus(filters.Status),
216
+		Conclusion:    nullableWorkflowRunConclusion(filters.Conclusion),
217
+		ActorUsername: nullableText(filters.Actor),
218
+	}
219
+}
220
+
221
+func workflowRunCountParams(repoID int64, filters actionsListFilters) actionsdb.CountWorkflowRunsForRepoParams {
222
+	return actionsdb.CountWorkflowRunsForRepoParams{
223
+		RepoID:        repoID,
224
+		WorkflowFile:  nullableText(filters.Workflow),
225
+		HeadRef:       nullableText(filters.Branch),
226
+		Event:         nullableWorkflowRunEvent(filters.Event),
227
+		Status:        nullableWorkflowRunStatus(filters.Status),
228
+		Conclusion:    nullableWorkflowRunConclusion(filters.Conclusion),
229
+		ActorUsername: nullableText(filters.Actor),
230
+	}
231
+}
232
+
233
+func nullableText(v string) pgtype.Text {
234
+	if v == "" {
235
+		return pgtype.Text{}
236
+	}
237
+	return pgtype.Text{String: v, Valid: true}
238
+}
239
+
240
+func nullableWorkflowRunEvent(v string) actionsdb.NullWorkflowRunEvent {
241
+	if v == "" {
242
+		return actionsdb.NullWorkflowRunEvent{}
243
+	}
244
+	return actionsdb.NullWorkflowRunEvent{WorkflowRunEvent: actionsdb.WorkflowRunEvent(v), Valid: true}
245
+}
246
+
247
+func nullableWorkflowRunStatus(v string) actionsdb.NullWorkflowRunStatus {
248
+	if v == "" {
249
+		return actionsdb.NullWorkflowRunStatus{}
250
+	}
251
+	return actionsdb.NullWorkflowRunStatus{WorkflowRunStatus: actionsdb.WorkflowRunStatus(v), Valid: true}
252
+}
253
+
254
+func nullableWorkflowRunConclusion(v string) actionsdb.NullCheckConclusion {
255
+	if v == "" {
256
+		return actionsdb.NullCheckConclusion{}
257
+	}
258
+	return actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusion(v), Valid: true}
259
+}
260
+
261
+func actionsWorkflowViews(rows []actionsdb.ListWorkflowRunWorkflowsForRepoRow, filters actionsListFilters, basePath string) ([]actionsWorkflowView, int64, string) {
262
+	params := actionsFilterParams(filters)
263
+	params.Del("page")
264
+	out := make([]actionsWorkflowView, 0, len(rows))
265
+	var total int64
266
+	activeName := ""
267
+	for _, row := range rows {
268
+		total += row.RunCount
269
+		name := workflowDisplayName(row.WorkflowName, row.WorkflowFile)
270
+		p := cloneValues(params)
271
+		p.Set("workflow", row.WorkflowFile)
272
+		active := filters.Workflow == row.WorkflowFile
273
+		if active {
274
+			activeName = name
275
+		}
276
+		out = append(out, actionsWorkflowView{
277
+			File:   row.WorkflowFile,
278
+			Name:   name,
279
+			Count:  row.RunCount,
280
+			Href:   pathWithQuery(basePath, p),
281
+			Active: active,
282
+		})
283
+	}
284
+	return out, total, activeName
285
+}
286
+
287
+func actionsListRunViewFromRow(row actionsdb.ListWorkflowRunsForRepoRow, owner, repoName string, now time.Time) actionsListRunView {
288
+	stateText, stateClass, stateIcon := workflowRunState(row.Status, row.Conclusion)
289
+	title := workflowDisplayName(row.WorkflowName, row.WorkflowFile)
290
+	updatedAt := row.UpdatedAt.Time
291
+	if updatedAt.IsZero() {
292
+		updatedAt = row.CreatedAt.Time
293
+	}
294
+	return actionsListRunView{
295
+		ID:            row.ID,
296
+		RunIndex:      row.RunIndex,
297
+		WorkflowFile:  row.WorkflowFile,
298
+		WorkflowName:  row.WorkflowName,
299
+		Title:         title,
300
+		HeadSha:       row.HeadSha,
301
+		HeadShaShort:  shortSHA(row.HeadSha),
302
+		HeadRef:       row.HeadRef,
303
+		Event:         string(row.Event),
304
+		EventLabel:    workflowRunEventLabel(string(row.Event)),
305
+		ActorUsername: row.ActorUsername,
306
+		StateText:     stateText,
307
+		StateClass:    stateClass,
308
+		StateIcon:     stateIcon,
309
+		CreatedAt:     row.CreatedAt.Time,
310
+		UpdatedAt:     updatedAt,
311
+		Duration:      workflowRunDuration(row.Status, row.StartedAt, row.CompletedAt, row.CreatedAt, updatedAt, now),
312
+		Href:          "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(row.RunIndex, 10),
313
+	}
314
+}
315
+
316
+func workflowDisplayName(name, file string) string {
317
+	name = strings.TrimSpace(name)
318
+	if name != "" {
319
+		return name
320
+	}
321
+	base := path.Base(file)
322
+	ext := path.Ext(base)
323
+	if ext != "" {
324
+		base = strings.TrimSuffix(base, ext)
325
+	}
326
+	if base == "." || base == "/" || base == "" {
327
+		return file
328
+	}
329
+	return base
330
+}
331
+
332
+func workflowRunState(status actionsdb.WorkflowRunStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
333
+	switch status {
334
+	case actionsdb.WorkflowRunStatusQueued:
335
+		return "Queued", "pending", "dot-fill"
336
+	case actionsdb.WorkflowRunStatusRunning:
337
+		return "In progress", "running", "dot-fill"
338
+	case actionsdb.WorkflowRunStatusCancelled:
339
+		return "Cancelled", "neutral", "x-circle"
340
+	case actionsdb.WorkflowRunStatusCompleted:
341
+		if !conclusion.Valid {
342
+			return "Completed", "neutral", "check-circle"
343
+		}
344
+	default:
345
+		if !conclusion.Valid {
346
+			return string(status), "neutral", "dot-fill"
347
+		}
348
+	}
349
+	switch conclusion.CheckConclusion {
350
+	case actionsdb.CheckConclusionSuccess, actionsdb.CheckConclusionSkipped, actionsdb.CheckConclusionNeutral:
351
+		return "Success", "success", "check-circle-fill"
352
+	case actionsdb.CheckConclusionFailure, actionsdb.CheckConclusionTimedOut, actionsdb.CheckConclusionActionRequired:
353
+		return "Failure", "failure", "x-circle-fill"
354
+	case actionsdb.CheckConclusionCancelled, actionsdb.CheckConclusionStale:
355
+		return "Cancelled", "neutral", "x-circle"
356
+	default:
357
+		return string(conclusion.CheckConclusion), "neutral", "dot-fill"
358
+	}
359
+}
360
+
361
+func workflowRunDuration(status actionsdb.WorkflowRunStatus, startedAt, completedAt, createdAt pgtype.Timestamptz, updatedAt, now time.Time) string {
362
+	if status == actionsdb.WorkflowRunStatusQueued {
363
+		return "—"
364
+	}
365
+	start := createdAt.Time
366
+	if startedAt.Valid {
367
+		start = startedAt.Time
368
+	}
369
+	end := updatedAt
370
+	if status == actionsdb.WorkflowRunStatusRunning {
371
+		end = now
372
+	} else if completedAt.Valid {
373
+		end = completedAt.Time
374
+	}
375
+	return formatDuration(end.Sub(start))
376
+}
377
+
378
+func validWorkflowRunEvent(v string) string {
379
+	switch strings.TrimSpace(v) {
380
+	case "push", "pull_request", "schedule", "workflow_dispatch":
381
+		return strings.TrimSpace(v)
382
+	default:
383
+		return ""
384
+	}
385
+}
386
+
387
+func validWorkflowRunStatus(v string) string {
388
+	switch strings.TrimSpace(v) {
389
+	case "queued", "running", "completed", "cancelled":
390
+		return strings.TrimSpace(v)
391
+	default:
392
+		return ""
393
+	}
394
+}
395
+
396
+func validWorkflowRunConclusion(v string) string {
397
+	switch strings.TrimSpace(v) {
398
+	case "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale":
399
+		return strings.TrimSpace(v)
400
+	default:
401
+		return ""
402
+	}
403
+}
404
+
405
+func actionsEventOptions(selected string) []actionsFilterOption {
406
+	return selectedOptions(selected, []actionsFilterOption{
407
+		{Value: "", Label: "Any event"},
408
+		{Value: "push", Label: "push"},
409
+		{Value: "pull_request", Label: "pull_request"},
410
+		{Value: "schedule", Label: "schedule"},
411
+		{Value: "workflow_dispatch", Label: "workflow_dispatch"},
412
+	})
413
+}
414
+
415
+func actionsStatusOptions(selected string) []actionsFilterOption {
416
+	return selectedOptions(selected, []actionsFilterOption{
417
+		{Value: "", Label: "Any status"},
418
+		{Value: "queued", Label: "queued"},
419
+		{Value: "running", Label: "running"},
420
+		{Value: "completed", Label: "completed"},
421
+		{Value: "cancelled", Label: "cancelled"},
422
+	})
423
+}
424
+
425
+func actionsConclusionOptions(selected string) []actionsFilterOption {
426
+	return selectedOptions(selected, []actionsFilterOption{
427
+		{Value: "", Label: "Any conclusion"},
428
+		{Value: "success", Label: "success"},
429
+		{Value: "failure", Label: "failure"},
430
+		{Value: "neutral", Label: "neutral"},
431
+		{Value: "cancelled", Label: "cancelled"},
432
+		{Value: "skipped", Label: "skipped"},
433
+		{Value: "timed_out", Label: "timed_out"},
434
+		{Value: "action_required", Label: "action_required"},
435
+		{Value: "stale", Label: "stale"},
436
+	})
437
+}
438
+
439
+func selectedOptions(selected string, opts []actionsFilterOption) []actionsFilterOption {
440
+	out := make([]actionsFilterOption, len(opts))
441
+	copy(out, opts)
442
+	for i := range out {
443
+		out[i].Selected = out[i].Value == selected
444
+	}
445
+	return out
446
+}
447
+
448
+func workflowRunEventLabel(v string) string {
449
+	switch v {
450
+	case "pull_request":
451
+		return "pull request"
452
+	case "workflow_dispatch":
453
+		return "workflow dispatch"
454
+	default:
455
+		return v
456
+	}
457
+}
458
+
459
+func actionsPagination(basePath string, filters actionsListFilters, total, pageRows int64) actionsPaginationView {
460
+	offset := int64((filters.Page - 1) * actionsRunsPageSize)
461
+	view := actionsPaginationView{
462
+		Page:     filters.Page,
463
+		PageSize: actionsRunsPageSize,
464
+		Total:    total,
465
+		HasPrev:  filters.Page > 1,
466
+		HasNext:  offset+pageRows < total,
467
+	}
468
+	if total == 0 {
469
+		view.ResultText = "No workflow runs"
470
+		return view
471
+	}
472
+	view.Start = offset + 1
473
+	view.End = offset + pageRows
474
+	view.ResultText = strconv.FormatInt(view.Start, 10) + "-" + strconv.FormatInt(view.End, 10) + " of " + strconv.FormatInt(total, 10)
475
+	if view.HasPrev {
476
+		p := actionsFilterParams(filters)
477
+		if filters.Page <= 2 {
478
+			p.Del("page")
479
+		} else {
480
+			p.Set("page", strconv.FormatInt(int64(filters.Page-1), 10))
481
+		}
482
+		view.PrevHref = pathWithQuery(basePath, p)
483
+	}
484
+	if view.HasNext {
485
+		p := actionsFilterParams(filters)
486
+		p.Set("page", strconv.FormatInt(int64(filters.Page+1), 10))
487
+		view.NextHref = pathWithQuery(basePath, p)
488
+	}
489
+	return view
490
+}
491
+
492
+func actionsFilterParams(filters actionsListFilters) url.Values {
493
+	v := url.Values{}
494
+	if filters.Workflow != "" {
495
+		v.Set("workflow", filters.Workflow)
496
+	}
497
+	if filters.Branch != "" {
498
+		v.Set("branch", filters.Branch)
499
+	}
500
+	if filters.Event != "" {
501
+		v.Set("event", filters.Event)
502
+	}
503
+	if filters.Status != "" {
504
+		v.Set("status", filters.Status)
505
+	}
506
+	if filters.Conclusion != "" {
507
+		v.Set("conclusion", filters.Conclusion)
508
+	}
509
+	if filters.Actor != "" {
510
+		v.Set("actor", filters.Actor)
511
+	}
512
+	if filters.Page > 1 {
513
+		v.Set("page", strconv.FormatInt(int64(filters.Page), 10))
514
+	}
515
+	return v
516
+}
517
+
518
+func cloneValues(v url.Values) url.Values {
519
+	out := url.Values{}
520
+	for key, values := range v {
521
+		for _, value := range values {
522
+			out.Add(key, value)
523
+		}
524
+	}
525
+	return out
526
+}
527
+
528
+func pathWithQuery(basePath string, q url.Values) string {
529
+	if encoded := q.Encode(); encoded != "" {
530
+		return basePath + "?" + encoded
531
+	}
532
+	return basePath
533
+}
534
+
105
 func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
535
 func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
106
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
536
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
107
 	if !ok {
537
 	if !ok {
@@ -142,24 +572,6 @@ func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
142
 	}
572
 	}
143
 }
573
 }
144
 
574
 
145
-func actionsSuiteViewFromListRow(row checksdb.ListCheckSuitesForRepoRow, runs []checksdb.CheckRun) actionsSuiteView {
146
-	return actionsSuiteViewFromParts(
147
-		row.ID,
148
-		row.HeadSha,
149
-		row.AppSlug,
150
-		row.Status,
151
-		row.Conclusion,
152
-		row.CreatedAt.Time,
153
-		row.UpdatedAt.Time,
154
-		row.PullNumber,
155
-		row.PullTitle,
156
-		row.PullAuthorUsername,
157
-		row.HeadRef,
158
-		row.BaseRef,
159
-		runs,
160
-	)
161
-}
162
-
163
 func actionsSuiteViewFromGetRow(row checksdb.GetCheckSuiteForRepoRow, runs []checksdb.CheckRun) actionsSuiteView {
575
 func actionsSuiteViewFromGetRow(row checksdb.GetCheckSuiteForRepoRow, runs []checksdb.CheckRun) actionsSuiteView {
164
 	return actionsSuiteViewFromParts(
576
 	return actionsSuiteViewFromParts(
165
 		row.ID,
577
 		row.ID,
@@ -197,7 +609,7 @@ func actionsSuiteViewFromParts(
197
 	if title == "" {
609
 	if title == "" {
198
 		title = appSlug + " checks for " + shortSHA(headSHA)
610
 		title = appSlug + " checks for " + shortSHA(headSHA)
199
 	}
611
 	}
200
-	stateText, stateClass, stateIcon := actionState(status, conclusion)
612
+	stateText, stateClass, stateIcon := checkActionState(status, conclusion)
201
 	runViews := make([]actionsRunView, 0, len(runs))
613
 	runViews := make([]actionsRunView, 0, len(runs))
202
 	annotationCount := 0
614
 	annotationCount := 0
203
 	for _, run := range runs {
615
 	for _, run := range runs {
@@ -230,7 +642,7 @@ func actionsSuiteViewFromParts(
230
 }
642
 }
231
 
643
 
232
 func actionsRunViewFromRun(run checksdb.CheckRun) actionsRunView {
644
 func actionsRunViewFromRun(run checksdb.CheckRun) actionsRunView {
233
-	stateText, stateClass, stateIcon := actionState(run.Status, run.Conclusion)
645
+	stateText, stateClass, stateIcon := checkActionState(run.Status, run.Conclusion)
234
 	start := run.CreatedAt.Time
646
 	start := run.CreatedAt.Time
235
 	if run.StartedAt.Valid {
647
 	if run.StartedAt.Valid {
236
 		start = run.StartedAt.Time
648
 		start = run.StartedAt.Time
@@ -252,13 +664,13 @@ func actionsRunViewFromRun(run checksdb.CheckRun) actionsRunView {
252
 	}
664
 	}
253
 }
665
 }
254
 
666
 
255
-func actionState(status checksdb.CheckStatus, conclusion checksdb.NullCheckConclusion) (string, string, string) {
667
+func checkActionState(status checksdb.CheckStatus, conclusion checksdb.NullCheckConclusion) (string, string, string) {
256
 	if !conclusion.Valid {
668
 	if !conclusion.Valid {
257
 		switch status {
669
 		switch status {
258
 		case checksdb.CheckStatusCompleted:
670
 		case checksdb.CheckStatusCompleted:
259
 			return "Completed", "neutral", "check-circle"
671
 			return "Completed", "neutral", "check-circle"
260
 		case checksdb.CheckStatusInProgress:
672
 		case checksdb.CheckStatusInProgress:
261
-			return "In progress", "pending", "dot-fill"
673
+			return "In progress", "running", "dot-fill"
262
 		case checksdb.CheckStatusQueued, checksdb.CheckStatusPending:
674
 		case checksdb.CheckStatusQueued, checksdb.CheckStatusPending:
263
 			return "Queued", "pending", "dot-fill"
675
 			return "Queued", "pending", "dot-fill"
264
 		default:
676
 		default:
internal/web/static/css/shithub.cssmodified
@@ -4852,7 +4852,7 @@ button.shithub-repo-action {
4852
 }
4852
 }
4853
 .shithub-actions-filters {
4853
 .shithub-actions-filters {
4854
   display: grid;
4854
   display: grid;
4855
-  grid-template-columns: minmax(16rem, 1fr) repeat(3, auto);
4855
+  grid-template-columns: minmax(9rem, 1fr) repeat(3, minmax(8rem, 0.7fr)) minmax(8rem, 0.8fr) auto auto;
4856
   gap: 0.5rem;
4856
   gap: 0.5rem;
4857
   margin-bottom: 1rem;
4857
   margin-bottom: 1rem;
4858
 }
4858
 }
@@ -4875,6 +4875,19 @@ button.shithub-repo-action {
4875
   background: transparent;
4875
   background: transparent;
4876
   color: var(--fg-muted);
4876
   color: var(--fg-muted);
4877
 }
4877
 }
4878
+.shithub-actions-search input:focus {
4879
+  color: var(--fg-default);
4880
+}
4881
+.shithub-actions-select select {
4882
+  width: 100%;
4883
+  min-height: 2.05rem;
4884
+  padding: 0.35rem 2rem 0.35rem 0.55rem;
4885
+  border: 1px solid var(--border-default);
4886
+  border-radius: 6px;
4887
+  background: var(--canvas-default);
4888
+  color: var(--fg-default);
4889
+  font: inherit;
4890
+}
4878
 .shithub-actions-filter-button {
4891
 .shithub-actions-filter-button {
4879
   display: inline-flex;
4892
   display: inline-flex;
4880
   align-items: center;
4893
   align-items: center;
@@ -4951,7 +4964,34 @@ button.shithub-repo-action {
4951
 .shithub-actions-state-success { color: #1a7f37; }
4964
 .shithub-actions-state-success { color: #1a7f37; }
4952
 .shithub-actions-state-failure { color: #cf222e; }
4965
 .shithub-actions-state-failure { color: #cf222e; }
4953
 .shithub-actions-state-pending { color: #9a6700; }
4966
 .shithub-actions-state-pending { color: #9a6700; }
4967
+.shithub-actions-state-running {
4968
+  color: #bc4c00;
4969
+}
4970
+.shithub-actions-state-running svg {
4971
+  animation: shithub-actions-running-pulse 1s ease-in-out infinite;
4972
+}
4954
 .shithub-actions-state-neutral { color: var(--fg-muted); }
4973
 .shithub-actions-state-neutral { color: var(--fg-muted); }
4974
+.shithub-actions-pagination {
4975
+  display: flex;
4976
+  align-items: center;
4977
+  justify-content: space-between;
4978
+  gap: 1rem;
4979
+  padding: 1rem 0;
4980
+  color: var(--fg-muted);
4981
+}
4982
+.shithub-actions-pagination > div {
4983
+  display: flex;
4984
+  gap: 0.5rem;
4985
+}
4986
+@keyframes shithub-actions-running-pulse {
4987
+  0%, 100% { transform: scale(0.82); opacity: 0.72; }
4988
+  50% { transform: scale(1); opacity: 1; }
4989
+}
4990
+@media (prefers-reduced-motion: reduce) {
4991
+  .shithub-actions-state-running svg {
4992
+    animation: none;
4993
+  }
4994
+}
4955
 .shithub-actions-empty {
4995
 .shithub-actions-empty {
4956
   padding: 4rem 1rem;
4996
   padding: 4rem 1rem;
4957
   border: 1px solid var(--border-default);
4997
   border: 1px solid var(--border-default);
internal/web/templates/repo/actions.htmlmodified
@@ -2,7 +2,7 @@
2
 {{ template "repo-header" . }}
2
 {{ template "repo-header" . }}
3
 <section class="shithub-actions-page">
3
 <section class="shithub-actions-page">
4
   <aside class="shithub-actions-sidebar" aria-label="Actions navigation">
4
   <aside class="shithub-actions-sidebar" aria-label="Actions navigation">
5
-    <a href="/{{ .Owner }}/{{ .Repo.Name }}/actions" class="shithub-actions-nav-item is-active" aria-current="page">
5
+    <a href="/{{ .Owner }}/{{ .Repo.Name }}/actions" class="shithub-actions-nav-item{{ if not .Filters.Workflow }} is-active{{ end }}"{{ if not .Filters.Workflow }} aria-current="page"{{ end }}>
6
       {{ octicon "play" }} <span>All workflows</span>
6
       {{ octicon "play" }} <span>All workflows</span>
7
       <span class="shithub-actions-nav-count">{{ .RunCount }}</span>
7
       <span class="shithub-actions-nav-count">{{ .RunCount }}</span>
8
     </a>
8
     </a>
@@ -10,7 +10,7 @@
10
       <h2>Workflows</h2>
10
       <h2>Workflows</h2>
11
       {{ if .Workflows }}
11
       {{ if .Workflows }}
12
         {{ range .Workflows }}
12
         {{ range .Workflows }}
13
-          <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/actions?workflow={{ .Name }}" class="shithub-actions-nav-item">
13
+          <a href="{{ .Href }}" class="shithub-actions-nav-item{{ if .Active }} is-active{{ end }}"{{ if .Active }} aria-current="page"{{ end }}>
14
             {{ octicon "workflow" }} <span>{{ .Name }}</span>
14
             {{ octicon "workflow" }} <span>{{ .Name }}</span>
15
             <span class="shithub-actions-nav-count">{{ .Count }}</span>
15
             <span class="shithub-actions-nav-count">{{ .Count }}</span>
16
           </a>
16
           </a>
@@ -24,40 +24,60 @@
24
   <div class="shithub-actions-main">
24
   <div class="shithub-actions-main">
25
     <header class="shithub-actions-head">
25
     <header class="shithub-actions-head">
26
       <div>
26
       <div>
27
-        <h1>All workflows</h1>
27
+        <h1>{{ if .ActiveWorkflowName }}{{ .ActiveWorkflowName }}{{ else }}All workflows{{ end }}</h1>
28
-        <p>Showing runs from check suites reported for this repository.</p>
28
+        <p>{{ .Pagination.ResultText }}</p>
29
       </div>
29
       </div>
30
-      <a class="shithub-button" href="/{{ .Owner }}/{{ .Repo.Name }}/settings/branches">{{ octicon "gear" }} Status checks</a>
30
+      <a class="shithub-button" href="/{{ .Owner }}/{{ .Repo.Name }}/settings/secrets/actions">{{ octicon "gear" }} Actions settings</a>
31
     </header>
31
     </header>
32
 
32
 
33
-    <div class="shithub-actions-filters" aria-label="Workflow run filters">
33
+    <form class="shithub-actions-filters" method="GET" action="/{{ .Owner }}/{{ .Repo.Name }}/actions" aria-label="Workflow run filters">
34
-      <div class="shithub-actions-search">
34
+      {{ if .Filters.Workflow }}<input type="hidden" name="workflow" value="{{ .Filters.Workflow }}">{{ end }}
35
-        {{ octicon "search" }}
35
+      <label class="shithub-actions-search">
36
-        <input type="search" placeholder="Filter workflow runs" aria-label="Filter workflow runs" disabled>
36
+        {{ octicon "git-branch" }}
37
-      </div>
37
+        <span class="sr-only">Branch</span>
38
-      <span class="shithub-actions-filter-button">Event {{ octicon "triangle-down" }}</span>
38
+        <input type="search" name="branch" value="{{ .Filters.Branch }}" placeholder="Branch">
39
-      <span class="shithub-actions-filter-button">Status {{ octicon "triangle-down" }}</span>
39
+      </label>
40
-      <span class="shithub-actions-filter-button">Branch {{ octicon "triangle-down" }}</span>
40
+      <label class="shithub-actions-select">
41
-    </div>
41
+        <span class="sr-only">Event</span>
42
+        <select name="event" aria-label="Event">
43
+          {{ range .EventOptions }}<option value="{{ .Value }}"{{ if .Selected }} selected{{ end }}>{{ .Label }}</option>{{ end }}
44
+        </select>
45
+      </label>
46
+      <label class="shithub-actions-select">
47
+        <span class="sr-only">Status</span>
48
+        <select name="status" aria-label="Status">
49
+          {{ range .StatusOptions }}<option value="{{ .Value }}"{{ if .Selected }} selected{{ end }}>{{ .Label }}</option>{{ end }}
50
+        </select>
51
+      </label>
52
+      <label class="shithub-actions-select">
53
+        <span class="sr-only">Conclusion</span>
54
+        <select name="conclusion" aria-label="Conclusion">
55
+          {{ range .ConclusionOptions }}<option value="{{ .Value }}"{{ if .Selected }} selected{{ end }}>{{ .Label }}</option>{{ end }}
56
+        </select>
57
+      </label>
58
+      <label class="shithub-actions-search shithub-actions-actor-filter">
59
+        {{ octicon "person" }}
60
+        <span class="sr-only">Actor</span>
61
+        <input type="search" name="actor" value="{{ .Filters.Actor }}" placeholder="Actor">
62
+      </label>
63
+      <button type="submit" class="shithub-button">{{ octicon "search" }} Filter</button>
64
+      {{ if .Filters.HasAny }}<a class="shithub-button" href="/{{ .Owner }}/{{ .Repo.Name }}/actions">Clear</a>{{ end }}
65
+    </form>
42
 
66
 
43
-    {{ if .Suites }}
67
+    {{ if .Runs }}
44
       <div class="shithub-actions-runs" aria-label="Workflow runs">
68
       <div class="shithub-actions-runs" aria-label="Workflow runs">
45
-        {{ range .Suites }}
69
+        {{ range .Runs }}
46
           <article class="shithub-actions-run-row">
70
           <article class="shithub-actions-run-row">
47
             <div class="shithub-actions-run-primary">
71
             <div class="shithub-actions-run-primary">
48
-              <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/actions/runs/{{ .ID }}" class="shithub-actions-run-title" aria-label="{{ .StateText }}: {{ .Title }}">
72
+              <a href="{{ .Href }}" class="shithub-actions-run-title" aria-label="{{ .StateText }}: {{ .Title }}">
49
                 <span class="shithub-actions-state shithub-actions-state-{{ .StateClass }}">{{ octicon .StateIcon }}</span>
73
                 <span class="shithub-actions-state shithub-actions-state-{{ .StateClass }}">{{ octicon .StateIcon }}</span>
50
                 <span>{{ .Title }}</span>
74
                 <span>{{ .Title }}</span>
51
               </a>
75
               </a>
52
               <p>
76
               <p>
53
-                <strong>{{ .AppSlug }}</strong>
77
+                <strong>#{{ .RunIndex }}</strong>
54
-                #{{ .ID }}:
78
+                triggered via {{ .EventLabel }}
55
-                {{ if .PullNumber }}
79
+                {{ if .ActorUsername }}by <a href="/{{ .ActorUsername }}">{{ .ActorUsername }}</a>{{ end }}
56
-                  Pull request <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ .PullNumber }}">#{{ .PullNumber }}</a>
80
+                for <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .HeadSha }}"><code>{{ .HeadShaShort }}</code></a>
57
-                  {{ if .PullAuthorUsername }}opened by <a href="/{{ .PullAuthorUsername }}">{{ .PullAuthorUsername }}</a>{{ end }}
58
-                {{ else }}
59
-                  check suite for <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .HeadSha }}"><code>{{ .HeadShaShort }}</code></a>
60
-                {{ end }}
61
               </p>
81
               </p>
62
             </div>
82
             </div>
63
             <div class="shithub-actions-run-branch">
83
             <div class="shithub-actions-run-branch">
@@ -71,11 +91,18 @@
71
           </article>
91
           </article>
72
         {{ end }}
92
         {{ end }}
73
       </div>
93
       </div>
94
+      <nav class="shithub-actions-pagination" aria-label="Workflow run pagination">
95
+        <span>{{ .Pagination.ResultText }}</span>
96
+        <div>
97
+          {{ if .Pagination.HasPrev }}<a class="shithub-button" href="{{ .Pagination.PrevHref }}">Previous</a>{{ else }}<span class="shithub-button shithub-button-disabled" aria-disabled="true">Previous</span>{{ end }}
98
+          {{ if .Pagination.HasNext }}<a class="shithub-button" href="{{ .Pagination.NextHref }}">Next</a>{{ else }}<span class="shithub-button shithub-button-disabled" aria-disabled="true">Next</span>{{ end }}
99
+        </div>
100
+      </nav>
74
     {{ else }}
101
     {{ else }}
75
       <section class="shithub-actions-empty">
102
       <section class="shithub-actions-empty">
76
         <div class="shithub-actions-empty-icon">{{ octicon "play" }}</div>
103
         <div class="shithub-actions-empty-icon">{{ octicon "play" }}</div>
77
-        <h2>There are no workflow runs yet</h2>
104
+        <h2>{{ if .Filters.HasAny }}No workflow runs match these filters{{ else }}There are no workflow runs yet{{ end }}</h2>
78
-        <p>Check runs posted via the shithub checks API will appear here and on pull requests.</p>
105
+        <p>{{ if .Filters.HasAny }}Clear or adjust the filters to see other runs.{{ else }}Runs from `.shithub/workflows/*.yml` will appear here after a trigger enqueues them.{{ end }}</p>
79
       </section>
106
       </section>
80
     {{ end }}
107
     {{ end }}
81
   </div>
108
   </div>