tenseleyflow/shithub / 62fa3fd

Browse files

actions/ui: add navigation shell placeholders

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
62fa3fd23b9562f92d1b75c18c3aee530bc38a7e
Parents
b03b58d
Tree
81b19bc

11 changed files

StatusFile+-
M internal/web/handlers/handlers_test.go 50 0
M internal/web/handlers/repo/actions.go 55 0
A internal/web/handlers/repo/actions_management.go 176 0
M internal/web/handlers/repo/actions_test.go 58 0
M internal/web/handlers/repo/repo.go 5 0
M internal/web/handlers/repo/repo_test.go 2 1
M internal/web/render/octicons.go 6 0
M internal/web/static/css/shithub.css 175 6
A internal/web/templates/repo/_actions_sidebar.html 35 0
M internal/web/templates/repo/actions.html 1 20
A internal/web/templates/repo/actions_management.html 65 0
internal/web/handlers/handlers_test.gomodified
@@ -177,3 +177,53 @@ func TestActionsLogStreamRouteBypassesCompressAndTimeout(t *testing.T) {
177177
 		t.Fatalf("body: got %q, want no-deadline", got)
178178
 	}
179179
 }
180
+
181
+func TestActionsManagementRoutesStayBeforeProfileCatchAll(t *testing.T) {
182
+	t.Parallel()
183
+
184
+	mux := http.NewServeMux()
185
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
186
+	if err := Register(mux, Deps{
187
+		Logger:      logger,
188
+		TemplatesFS: testTemplatesFS(t),
189
+		StaticFS:    testStaticFS(t),
190
+		LogoSVG:     `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`,
191
+		RepoHomeMounter: func(r chi.Router) {
192
+			r.Get("/{owner}/{repo}/actions/caches", writeRouteName("actions:caches"))
193
+			r.Get("/{owner}/{repo}/actions/attestations", writeRouteName("actions:attestations"))
194
+			r.Get("/{owner}/{repo}/actions/runners", writeRouteName("actions:runners"))
195
+			r.Get("/{owner}/{repo}/actions/metrics/usage", writeRouteName("actions:usage"))
196
+			r.Get("/{owner}/{repo}/actions/metrics/performance", writeRouteName("actions:performance"))
197
+		},
198
+		ProfileMounter: func(r chi.Router) {
199
+			r.Get("/{username}", writeRouteName("profile"))
200
+		},
201
+	}); err != nil {
202
+		t.Fatalf("Register: %v", err)
203
+	}
204
+
205
+	tests := map[string]string{
206
+		"/octo/demo/actions/caches":              "actions:caches",
207
+		"/octo/demo/actions/attestations":        "actions:attestations",
208
+		"/octo/demo/actions/runners":             "actions:runners",
209
+		"/octo/demo/actions/metrics/usage":       "actions:usage",
210
+		"/octo/demo/actions/metrics/performance": "actions:performance",
211
+	}
212
+	for path, want := range tests {
213
+		req := httptest.NewRequest(http.MethodGet, path, nil)
214
+		rec := httptest.NewRecorder()
215
+		mux.ServeHTTP(rec, req)
216
+		if rec.Code != http.StatusOK {
217
+			t.Fatalf("%s: status got %d want 200 body=%q", path, rec.Code, rec.Body.String())
218
+		}
219
+		if got := rec.Body.String(); got != want {
220
+			t.Fatalf("%s: body got %q want %q", path, got, want)
221
+		}
222
+	}
223
+}
224
+
225
+func writeRouteName(name string) http.HandlerFunc {
226
+	return func(w http.ResponseWriter, _ *http.Request) {
227
+		_, _ = io.WriteString(w, name)
228
+	}
229
+}
internal/web/handlers/repo/actions.gomodified
@@ -36,6 +36,22 @@ type actionsWorkflowView struct {
3636
 	Active bool
3737
 }
3838
 
39
+type actionsSidebarView struct {
40
+	AllHref     string
41
+	AllRunCount int64
42
+	AllActive   bool
43
+	Workflows   []actionsWorkflowView
44
+	Management  []actionsManagementNavItem
45
+}
46
+
47
+type actionsManagementNavItem struct {
48
+	Key    string
49
+	Label  string
50
+	Icon   string
51
+	Href   string
52
+	Active bool
53
+}
54
+
3955
 type actionsListRunView struct {
4056
 	ID            int64
4157
 	RunIndex      int64
@@ -221,6 +237,7 @@ func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
221237
 
222238
 	basePath := "/" + owner.Username + "/" + row.Name + "/actions"
223239
 	workflows, allRunCount, activeWorkflowName := actionsWorkflowViews(workflowRows, filters, basePath)
240
+	sidebar := actionsSidebar(basePath, workflows, allRunCount, filters.Workflow == "", "")
224241
 	runViews := make([]actionsListRunView, 0, len(runs))
225242
 	now := time.Now()
226243
 	for _, run := range runs {
@@ -235,6 +252,7 @@ func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
235252
 	data["Title"] = "Actions · " + row.Name
236253
 	data["Runs"] = runViews
237254
 	data["Workflows"] = workflows
255
+	data["ActionsSidebar"] = sidebar
238256
 	data["DispatchWorkflows"] = dispatchWorkflows
239257
 	data["RunCount"] = allRunCount
240258
 	data["FilteredRunCount"] = filteredCount
@@ -249,6 +267,43 @@ func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
249267
 	}
250268
 }
251269
 
270
+func actionsSidebar(basePath string, workflows []actionsWorkflowView, allRunCount int64, allActive bool, activeManagement string) actionsSidebarView {
271
+	if activeManagement != "" {
272
+		allActive = false
273
+		workflows = inactiveActionsWorkflows(workflows)
274
+	}
275
+	return actionsSidebarView{
276
+		AllHref:     basePath,
277
+		AllRunCount: allRunCount,
278
+		AllActive:   allActive,
279
+		Workflows:   workflows,
280
+		Management:  actionsManagementNavItems(basePath, activeManagement),
281
+	}
282
+}
283
+
284
+func inactiveActionsWorkflows(workflows []actionsWorkflowView) []actionsWorkflowView {
285
+	out := make([]actionsWorkflowView, len(workflows))
286
+	copy(out, workflows)
287
+	for i := range out {
288
+		out[i].Active = false
289
+	}
290
+	return out
291
+}
292
+
293
+func actionsManagementNavItems(basePath, active string) []actionsManagementNavItem {
294
+	items := []actionsManagementNavItem{
295
+		{Key: "caches", Label: "Caches", Icon: "cache", Href: basePath + "/caches"},
296
+		{Key: "attestations", Label: "Attestations", Icon: "shield-check", Href: basePath + "/attestations"},
297
+		{Key: "runners", Label: "Runners", Icon: "server", Href: basePath + "/runners"},
298
+		{Key: "usage", Label: "Usage metrics", Icon: "pulse", Href: basePath + "/metrics/usage"},
299
+		{Key: "performance", Label: "Performance metrics", Icon: "stopwatch", Href: basePath + "/metrics/performance"},
300
+	}
301
+	for i := range items {
302
+		items[i].Active = items[i].Key == active
303
+	}
304
+	return items
305
+}
306
+
252307
 func actionsListFiltersFromRequest(r *http.Request) actionsListFilters {
253308
 	q := r.URL.Query()
254309
 	f := actionsListFilters{
internal/web/handlers/repo/actions_management.goadded
@@ -0,0 +1,176 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"net/http"
7
+
8
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
9
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
10
+)
11
+
12
+type actionsManagementPageView struct {
13
+	Key               string
14
+	Title             string
15
+	Description       string
16
+	Icon              string
17
+	SearchPlaceholder string
18
+	CountLabel        string
19
+	EmptyTitle        string
20
+	EmptyBody         string
21
+	PrimaryAction     string
22
+	PrimaryDisabled   bool
23
+	Stats             []actionsManagementStatView
24
+	Tabs              []actionsManagementTabView
25
+}
26
+
27
+type actionsManagementStatView struct {
28
+	Label string
29
+	Value string
30
+	Note  string
31
+}
32
+
33
+type actionsManagementTabView struct {
34
+	Label  string
35
+	Icon   string
36
+	Active bool
37
+}
38
+
39
+func (h *Handlers) repoActionsCaches(w http.ResponseWriter, r *http.Request) {
40
+	h.repoActionsManagementPage(w, r, actionsManagementPage("caches"))
41
+}
42
+
43
+func (h *Handlers) repoActionsAttestations(w http.ResponseWriter, r *http.Request) {
44
+	h.repoActionsManagementPage(w, r, actionsManagementPage("attestations"))
45
+}
46
+
47
+func (h *Handlers) repoActionsRunners(w http.ResponseWriter, r *http.Request) {
48
+	h.repoActionsManagementPage(w, r, actionsManagementPage("runners"))
49
+}
50
+
51
+func (h *Handlers) repoActionsUsageMetrics(w http.ResponseWriter, r *http.Request) {
52
+	h.repoActionsManagementPage(w, r, actionsManagementPage("usage"))
53
+}
54
+
55
+func (h *Handlers) repoActionsPerformanceMetrics(w http.ResponseWriter, r *http.Request) {
56
+	h.repoActionsManagementPage(w, r, actionsManagementPage("performance"))
57
+}
58
+
59
+func (h *Handlers) repoActionsManagementPage(w http.ResponseWriter, r *http.Request, page actionsManagementPageView) {
60
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
61
+	if !ok {
62
+		return
63
+	}
64
+
65
+	q := actionsdb.New()
66
+	workflowRows, err := q.ListWorkflowRunWorkflowsForRepo(r.Context(), h.d.Pool, row.ID)
67
+	if err != nil {
68
+		h.d.Logger.WarnContext(r.Context(), "repo actions: list workflows for management page", "repo_id", row.ID, "page", page.Key, "error", err)
69
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
70
+		return
71
+	}
72
+
73
+	basePath := "/" + owner.Username + "/" + row.Name + "/actions"
74
+	workflows, allRunCount, _ := actionsWorkflowViews(workflowRows, actionsListFilters{}, basePath)
75
+
76
+	data := h.repoHeaderData(r, row, owner.Username, "actions")
77
+	data["Title"] = page.Title + " · " + row.Name
78
+	data["Page"] = page
79
+	data["ActionsSidebar"] = actionsSidebar(basePath, workflows, allRunCount, false, page.Key)
80
+	data["RunCount"] = allRunCount
81
+	data["Workflows"] = workflows
82
+	if err := h.d.Render.RenderPage(w, r, "repo/actions_management", data); err != nil {
83
+		h.d.Logger.ErrorContext(r.Context(), "repo actions management render", "page", page.Key, "error", err)
84
+	}
85
+}
86
+
87
+func actionsManagementPage(key string) actionsManagementPageView {
88
+	switch key {
89
+	case "caches":
90
+		return actionsManagementPageView{
91
+			Key:               key,
92
+			Title:             "Caches",
93
+			Description:       "Showing caches from all workflows.",
94
+			Icon:              "cache",
95
+			SearchPlaceholder: "Filter caches",
96
+			CountLabel:        "0 caches",
97
+			EmptyTitle:        "No caches",
98
+			EmptyBody:         "Nothing has been cached by workflows running in this repository.",
99
+		}
100
+	case "attestations":
101
+		return actionsManagementPageView{
102
+			Key:               key,
103
+			Title:             "Attestations",
104
+			Description:       "Build provenance and artifact attestations for this repository.",
105
+			Icon:              "shield-check",
106
+			SearchPlaceholder: "Search or filter",
107
+			CountLabel:        "0 attestations",
108
+			EmptyTitle:        "No attestations",
109
+			EmptyBody:         "No workflow has published an artifact attestation for this repository yet.",
110
+		}
111
+	case "runners":
112
+		return actionsManagementPageView{
113
+			Key:               key,
114
+			Title:             "Runners",
115
+			Description:       "Runners available to this repository.",
116
+			Icon:              "server",
117
+			SearchPlaceholder: "Filter runners",
118
+			CountLabel:        "Runner management",
119
+			EmptyTitle:        "Repository runner management is coming later",
120
+			EmptyBody:         "Jobs can already use the shared shithub runner pool. Repository-scoped runner registration controls will land in a later Actions slice.",
121
+			PrimaryAction:     "New runner",
122
+			PrimaryDisabled:   true,
123
+			Tabs: []actionsManagementTabView{
124
+				{Label: "shithub-hosted runners", Icon: "server", Active: true},
125
+				{Label: "Self-hosted runners", Icon: "repo"},
126
+			},
127
+		}
128
+	case "usage":
129
+		return actionsManagementPageView{
130
+			Key:         key,
131
+			Title:       "Actions Usage Metrics",
132
+			Description: "Showing data for the current billing period.",
133
+			Icon:        "pulse",
134
+			CountLabel:  "No table data available yet",
135
+			EmptyTitle:  "No usage metrics available yet",
136
+			EmptyBody:   "Workflow minute and job-run aggregates will appear here after the Actions metering model is wired into the repository UI.",
137
+			Stats: []actionsManagementStatView{
138
+				{Label: "Total minutes", Value: "0", Note: "Total minutes across all workflows in this repository."},
139
+				{Label: "Total job runs", Value: "0", Note: "Total job runs across all workflows in this repository."},
140
+			},
141
+			Tabs: metricTabs("workflows"),
142
+		}
143
+	case "performance":
144
+		return actionsManagementPageView{
145
+			Key:         key,
146
+			Title:       "Actions Performance Metrics",
147
+			Description: "Showing data for the current month.",
148
+			Icon:        "stopwatch",
149
+			CountLabel:  "No table data available yet",
150
+			EmptyTitle:  "No performance metrics available yet",
151
+			EmptyBody:   "Average runtime, queue time, and failure-rate metrics will appear here once the insights pipeline is connected to the Actions UI.",
152
+			Stats: []actionsManagementStatView{
153
+				{Label: "Avg job run time", Value: "0s", Note: "Average run time of jobs in this repository."},
154
+				{Label: "Avg job queue time", Value: "0s", Note: "Average queue time of jobs in this repository."},
155
+				{Label: "Job failure rate", Value: "0%", Note: "Failure rate across jobs in this repository."},
156
+				{Label: "Failed job usage", Value: "0", Note: "Total minutes used by failed jobs."},
157
+			},
158
+			Tabs: metricTabs("workflows"),
159
+		}
160
+	default:
161
+		return actionsManagementPageView{Key: key, Title: "Actions", EmptyTitle: "Coming later"}
162
+	}
163
+}
164
+
165
+func metricTabs(active string) []actionsManagementTabView {
166
+	tabs := []actionsManagementTabView{
167
+		{Label: "Workflows", Icon: "workflow"},
168
+		{Label: "Jobs", Icon: "stopwatch"},
169
+		{Label: "Runtime OS", Icon: "server"},
170
+		{Label: "Runner type", Icon: "repo"},
171
+	}
172
+	for i := range tabs {
173
+		tabs[i].Active = active == "workflows" && i == 0
174
+	}
175
+	return tabs
176
+}
internal/web/handlers/repo/actions_test.gomodified
@@ -170,6 +170,59 @@ func TestRepoTabActionsRendersDispatchWorkflowsForWriters(t *testing.T) {
170170
 	}
171171
 }
172172
 
173
+func TestRepoActionsManagementPagesRenderPlaceholdersAndActiveNav(t *testing.T) {
174
+	t.Parallel()
175
+	f := newRepoFixture(t)
176
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
177
+	f.insertWorkflowRun(t, workflowRunFixture{
178
+		RunIndex:      1,
179
+		WorkflowFile:  ".shithub/workflows/ci.yml",
180
+		WorkflowName:  "CI",
181
+		HeadRef:       "trunk",
182
+		Event:         actionsdb.WorkflowRunEventPush,
183
+		Status:        actionsdb.WorkflowRunStatusCompleted,
184
+		Conclusion:    actionsdb.CheckConclusionSuccess,
185
+		ActorUserID:   f.owner.ID,
186
+		CreatedOffset: -time.Hour,
187
+		DoneOffset:    -time.Minute,
188
+	}, now)
189
+
190
+	tests := []struct {
191
+		path  string
192
+		key   string
193
+		title string
194
+		empty string
195
+	}{
196
+		{"/alice/public-repo/actions/caches", "caches", "Caches", "No caches"},
197
+		{"/alice/public-repo/actions/attestations", "attestations", "Attestations", "No attestations"},
198
+		{"/alice/public-repo/actions/runners", "runners", "Runners", "Repository runner management is coming later"},
199
+		{"/alice/public-repo/actions/metrics/usage", "usage", "Actions Usage Metrics", "No usage metrics available yet"},
200
+		{"/alice/public-repo/actions/metrics/performance", "performance", "Actions Performance Metrics", "No performance metrics available yet"},
201
+	}
202
+
203
+	for _, tt := range tests {
204
+		t.Run(tt.key, func(t *testing.T) {
205
+			resp := httptest.NewRecorder()
206
+			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
207
+			f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req)
208
+			if resp.Code != http.StatusOK {
209
+				t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
210
+			}
211
+			body := resp.Body.String()
212
+			for _, want := range []string{
213
+				"MGMT=" + tt.key + ":" + tt.title + ":" + tt.empty + ";",
214
+				"MGMTNAV=" + tt.key + ":true:",
215
+				"COUNT=1;",
216
+				"WF=CI:false;",
217
+			} {
218
+				if !strings.Contains(body, want) {
219
+					t.Fatalf("body missing %q in %s", want, body)
220
+				}
221
+			}
222
+		})
223
+	}
224
+}
225
+
173226
 func TestRepoActionsDispatchAcceptsFormInputs(t *testing.T) {
174227
 	t.Parallel()
175228
 	f := newRepoFixture(t)
@@ -906,6 +959,11 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
906959
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog)
907960
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus)
908961
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun)
962
+	mux.Get("/{owner}/{repo}/actions/caches", f.handlers.repoActionsCaches)
963
+	mux.Get("/{owner}/{repo}/actions/attestations", f.handlers.repoActionsAttestations)
964
+	mux.Get("/{owner}/{repo}/actions/runners", f.handlers.repoActionsRunners)
965
+	mux.Get("/{owner}/{repo}/actions/metrics/usage", f.handlers.repoActionsUsageMetrics)
966
+	mux.Get("/{owner}/{repo}/actions/metrics/performance", f.handlers.repoActionsPerformanceMetrics)
909967
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel)
910968
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", f.handlers.repoActionRunRerun)
911969
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/approve", f.handlers.repoActionRunApprove)
internal/web/handlers/repo/repo.gomodified
@@ -158,6 +158,11 @@ func (h *Handlers) MountRepoHome(r chi.Router) {
158158
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
159159
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
160160
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)
161
+	r.Get("/{owner}/{repo}/actions/caches", h.repoActionsCaches)
162
+	r.Get("/{owner}/{repo}/actions/attestations", h.repoActionsAttestations)
163
+	r.Get("/{owner}/{repo}/actions/runners", h.repoActionsRunners)
164
+	r.Get("/{owner}/{repo}/actions/metrics/usage", h.repoActionsUsageMetrics)
165
+	r.Get("/{owner}/{repo}/actions/metrics/performance", h.repoActionsPerformanceMetrics)
161166
 	r.Get("/{owner}/{repo}/actions", h.repoTabActions)
162167
 	r.Get("/{owner}/{repo}/projects", h.repoTabProjects)
163168
 	r.Get("/{owner}/{repo}/wiki", h.repoTabWiki)
internal/web/handlers/repo/repo_test.gomodified
@@ -158,7 +158,8 @@ func minimalTemplatesFS() fstest.MapFS {
158158
 		"errors/429.html":              {Data: body},
159159
 		"errors/500.html":              {Data: body},
160160
 		"repo/new.html":                {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
161
-		"repo/actions.html":            {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .DispatchWorkflows }}DISPATCH={{ .Name }}:{{ .DispatchHref }}:{{ range .Inputs }}{{ .Name }}/{{ .Type }}/{{ .Required }}/{{ .Default }}/{{ range .Options }}{{ .Value }}|{{ end }},{{ end }};{{ end }}{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
161
+		"repo/actions.html":            {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .ActionsSidebar.Management }}MGMTNAV={{ .Key }}:{{ .Active }}:{{ .Href }};{{ end }}{{ range .DispatchWorkflows }}DISPATCH={{ .Name }}:{{ .DispatchHref }}:{{ range .Inputs }}{{ .Name }}/{{ .Type }}/{{ .Required }}/{{ .Default }}/{{ range .Options }}{{ .Value }}|{{ end }},{{ end }};{{ end }}{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
162
+		"repo/actions_management.html": {Data: []byte(`{{ define "page" }}MGMT={{ .Page.Key }}:{{ .Page.Title }}:{{ .Page.EmptyTitle }};COUNT={{ .RunCount }};{{ range .ActionsSidebar.Management }}MGMTNAV={{ .Key }}:{{ .Active }}:{{ .Href }};{{ end }}{{ range .ActionsSidebar.Workflows }}WF={{ .Name }}:{{ .Active }};{{ end }}{{ end }}`)},
162163
 		"repo/_action_run_status.html": {Data: []byte(`{{ define "action-run-status" }}STATUS={{ .Run.StateClass }}:{{ .Run.IsTerminal }}:{{ .Run.StatusHref }};{{ end }}`)},
163164
 		"repo/action_run.html":         {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};{{ if .Run.ParentRunHref }}PARENT={{ .Run.ParentRunIndex }}:{{ .Run.ParentRunHref }};{{ end }}{{ if .Run.CanRerun }}RERUN={{ .Run.RerunHref }};{{ end }}{{ if .Run.CanCancel }}CANCEL_RUN={{ .Run.CancelHref }};{{ end }}{{ if .Run.CanApprove }}APPROVE={{ .Run.ApproveHref }};REJECT={{ .Run.RejectHref }};{{ end }}{{ if .Run.ApprovalPending }}APPROVAL_PENDING={{ .Run.ApprovalReason }};{{ end }}SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ if .WaitReason }}WAIT={{ .WaitReason }};{{ end }}{{ if .CanCancel }}CANCEL_JOB={{ .CancelHref }};{{ end }}{{ if .CancelRequested }}CANCEL_REQUESTED={{ .Name }};{{ end }}{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
164165
 		"repo/action_run_status.html":  {Data: []byte(`{{ define "page" }}{{ template "action-run-status" . }}{{ end }}`)},
internal/web/render/octicons.gomodified
@@ -139,6 +139,10 @@ func BuiltinOcticons() OcticonResolver {
139139
 			`><path d="M3.75 1A1.75 1.75 0 0 0 2 2.75v10.5C2 14.216 2.784 15 3.75 15h8.5A1.75 1.75 0 0 0 14 13.25V5.664c0-.464-.184-.909-.513-1.237L10.573 1.513A1.75 1.75 0 0 0 9.336 1Zm0 1.5h5.5v2.75c0 .414.336.75.75.75h2.5v7.25a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25V2.75a.25.25 0 0 1 .25-.25Zm7 .56 1.19 1.19h-1.19ZM5.25 8a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H6A.75.75 0 0 1 5.25 8Zm0 3a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Z"/></svg>`),
140140
 		"package": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
141141
 			`><path d="M7.66.23a.75.75 0 0 1 .68 0l6 3A.75.75 0 0 1 14.75 3.9v8.2a.75.75 0 0 1-.41.67l-6 3a.75.75 0 0 1-.68 0l-6-3a.75.75 0 0 1-.41-.67V3.9a.75.75 0 0 1 .41-.67Zm.34 1.51L3.67 3.9 8 6.06l4.33-2.16ZM2.75 5.1v6.54l4.5 2.25V7.36Zm6 8.79 4.5-2.25V5.1l-4.5 2.26Z"/></svg>`),
142
+		"cache": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
143
+			`><path d="M2.5 5.724V8c0 .248.238.7 1.169 1.159.874.43 2.144.745 3.62.822a.75.75 0 1 1-.078 1.498c-1.622-.085-3.102-.432-4.204-.975a5.565 5.565 0 0 1-.507-.28V12.5c0 .133.058.318.282.551.227.237.591.483 1.101.707 1.015.447 2.47.742 4.117.742.406 0 .802-.018 1.183-.052a.751.751 0 1 1 .134 1.494C8.89 15.98 8.45 16 8 16c-1.805 0-3.475-.32-4.721-.869-.623-.274-1.173-.619-1.579-1.041-.408-.425-.7-.964-.7-1.59v-9c0-.626.292-1.165.7-1.591.406-.42.956-.766 1.579-1.04C4.525.32 6.195 0 8 0c1.806 0 3.476.32 4.721.869.623.274 1.173.619 1.579 1.041.408.425.7.964.7 1.59 0 .626-.292 1.165-.7 1.591-.406.42-.956.766-1.578 1.04C11.475 6.68 9.805 7 8 7c-1.805 0-3.475-.32-4.721-.869a6.15 6.15 0 0 1-.779-.407Zm0-2.224c0 .133.058.318.282.551.227.237.591.483 1.101.707C4.898 5.205 6.353 5.5 8 5.5c1.646 0 3.101-.295 4.118-.742.508-.224.873-.471 1.1-.708.224-.232.282-.417.282-.55 0-.133-.058-.318-.282-.551-.227-.237-.591-.483-1.101-.707C11.102 1.795 9.647 1.5 8 1.5c-1.646 0-3.101.295-4.118.742-.508.224-.873.471-1.1.708-.224.232-.282.417-.282.55Z"></path><path d="M14.49 7.582a.375.375 0 0 0-.66-.313l-3.625 4.625a.375.375 0 0 0 .295.606h2.127l-.619 2.922a.375.375 0 0 0 .666.304l3.125-4.125A.375.375 0 0 0 15.5 11h-1.778l.769-3.418Z"></path></svg>`),
144
+		"server": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
145
+			`><path d="M1.75 2A1.75 1.75 0 0 0 0 3.75v2.5C0 7.216.784 8 1.75 8h12.5A1.75 1.75 0 0 0 16 6.25v-2.5A1.75 1.75 0 0 0 14.25 2Zm0 1.5h12.5a.25.25 0 0 1 .25.25v2.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-2.5a.25.25 0 0 1 .25-.25ZM1.75 9A1.75 1.75 0 0 0 0 10.75v1.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0 0 16 12.25v-1.5A1.75 1.75 0 0 0 14.25 9Zm0 1.5h12.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-1.5a.25.25 0 0 1 .25-.25ZM3 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm0 6.5a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"/></svg>`),
142146
 		"location": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
143147
 			`><path d="M8 0a5.5 5.5 0 0 1 5.5 5.5c0 4.05-4.69 8.75-5.22 9.27a.4.4 0 0 1-.56 0C7.19 14.25 2.5 9.55 2.5 5.5A5.5 5.5 0 0 1 8 0Zm0 1.5a4 4 0 0 0-4 4c0 2.59 2.64 5.83 4 7.33 1.36-1.5 4-4.74 4-7.33a4 4 0 0 0-4-4Zm0 6.25a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5Zm0-1.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"/></svg>`),
144148
 		"link": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
@@ -149,6 +153,8 @@ func BuiltinOcticons() OcticonResolver {
149153
 			`><path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/></svg>`),
150154
 		"workflow": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
151155
 			`><path d="M0 2.75C0 1.784.784 1 1.75 1h2.5C5.216 1 6 1.784 6 2.75v2.5A1.75 1.75 0 0 1 4.25 7H3.5v2h6.75A1.75 1.75 0 0 1 12 10.75V12h.75a.75.75 0 0 1 0 1.5H12v.75A1.75 1.75 0 0 1 10.25 16h-2.5A1.75 1.75 0 0 1 6 14.25v-2.5C6 10.784 6.784 10 7.75 10h2.5a.25.25 0 0 1 .25.25V10.75a.25.25 0 0 0-.25-.25H3.5v3.75a.75.75 0 0 1-1.5 0V7h-.25A1.75 1.75 0 0 1 0 5.25Zm1.75-.25a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Zm6 9a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
156
+		"pin": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
157
+			`><path d="m11.294.984 3.722 3.722a1.75 1.75 0 0 1-.504 2.826l-1.327.613a3.089 3.089 0 0 0-1.707 2.084l-.584 2.454c-.317 1.332-1.972 1.8-2.94.832L5.75 11.311 1.78 15.28a.749.749 0 1 1-1.06-1.06l3.969-3.97-2.204-2.204c-.968-.968-.5-2.623.832-2.94l2.454-.584a3.08 3.08 0 0 0 2.084-1.707l.613-1.327a1.75 1.75 0 0 1 2.826-.504ZM6.283 9.723l2.732 2.731a.25.25 0 0 0 .42-.119l.584-2.454a4.586 4.586 0 0 1 2.537-3.098l1.328-.613a.25.25 0 0 0 .072-.404l-3.722-3.722a.25.25 0 0 0-.404.072l-.613 1.328a4.584 4.584 0 0 1-3.098 2.537l-2.454.584a.25.25 0 0 0-.119.42l2.731 2.732Z"/></svg>`),
152158
 		"screen-full": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
153159
 			`><path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1h2.5a.75.75 0 0 1 0 1.5ZM10 1.75a.75.75 0 0 1 .75-.75h2.5c.966 0 1.75.784 1.75 1.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1-.75-.75Z"/></svg>`),
154160
 		"dash": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -5591,12 +5591,36 @@ button.shithub-repo-action {
55915591
   flex-direction: column;
55925592
   gap: 0.25rem;
55935593
 }
5594
+.shithub-actions-sidebar nav {
5595
+  display: grid;
5596
+  gap: 0.15rem;
5597
+}
5598
+.shithub-actions-sidebar-head {
5599
+  display: flex;
5600
+  align-items: center;
5601
+  justify-content: space-between;
5602
+  gap: 0.75rem;
5603
+  margin: 0.1rem 0 0.75rem;
5604
+}
5605
+.shithub-actions-sidebar-head h2 {
5606
+  margin: 0;
5607
+  font-size: 1.35rem;
5608
+  font-weight: 600;
5609
+}
5610
+.shithub-actions-new-workflow {
5611
+  min-height: 2rem;
5612
+  padding-inline: 0.7rem;
5613
+}
5614
+.shithub-actions-new-workflow.is-disabled {
5615
+  opacity: 0.78;
5616
+  cursor: not-allowed;
5617
+}
55945618
 .shithub-actions-sidebar-section {
5595
-  margin-top: 1rem;
5596
-  padding-top: 0.75rem;
5619
+  margin-top: 0.55rem;
5620
+  padding-top: 0.55rem;
55975621
   border-top: 1px solid var(--border-default);
55985622
 }
5599
-.shithub-actions-sidebar-section h2 {
5623
+.shithub-actions-sidebar-section h3 {
56005624
   margin: 0 0 0.4rem;
56015625
   padding: 0 0.55rem;
56025626
   color: var(--fg-muted);
@@ -5610,8 +5634,7 @@ button.shithub-repo-action {
56105634
   font-size: 0.85rem;
56115635
 }
56125636
 .shithub-actions-nav-item {
5613
-  display: grid;
5614
-  grid-template-columns: 1rem minmax(0, 1fr) auto;
5637
+  display: flex;
56155638
   align-items: center;
56165639
   gap: 0.5rem;
56175640
   min-height: 2rem;
@@ -5626,12 +5649,34 @@ button.shithub-repo-action {
56265649
   background: var(--canvas-subtle);
56275650
   text-decoration: none;
56285651
 }
5629
-.shithub-actions-nav-item span:nth-child(2) {
5652
+.shithub-actions-nav-item > span:not(.shithub-actions-nav-visual):not(.shithub-actions-pin-placeholder):not(.shithub-actions-state):not(.shithub-actions-nav-count) {
56305653
   overflow: hidden;
56315654
   text-overflow: ellipsis;
56325655
   white-space: nowrap;
56335656
 }
5657
+.shithub-actions-nav-item-visual {
5658
+  padding-left: 0.65rem;
5659
+}
5660
+.shithub-actions-nav-visual {
5661
+  display: inline-flex;
5662
+  align-items: center;
5663
+  justify-content: center;
5664
+  width: 1rem;
5665
+  color: var(--fg-muted);
5666
+  flex: 0 0 auto;
5667
+}
5668
+.shithub-actions-pin-placeholder {
5669
+  display: inline-flex;
5670
+  margin-left: auto;
5671
+  color: var(--fg-muted);
5672
+  opacity: 0;
5673
+}
5674
+.shithub-actions-nav-item:hover .shithub-actions-pin-placeholder,
5675
+.shithub-actions-nav-item:focus-visible .shithub-actions-pin-placeholder {
5676
+  opacity: 0.72;
5677
+}
56345678
 .shithub-actions-nav-count {
5679
+  margin-left: auto;
56355680
   color: var(--fg-muted);
56365681
   font-size: 0.78rem;
56375682
 }
@@ -5900,6 +5945,120 @@ button.shithub-repo-action {
59005945
   max-width: 34rem;
59015946
   color: var(--fg-muted);
59025947
 }
5948
+.shithub-actions-management-head {
5949
+  padding-bottom: 1rem;
5950
+  border-bottom: 1px solid var(--border-default);
5951
+}
5952
+.shithub-actions-metric-grid {
5953
+  display: grid;
5954
+  grid-template-columns: repeat(2, minmax(0, 1fr));
5955
+  gap: 1rem;
5956
+  margin-bottom: 1rem;
5957
+}
5958
+.shithub-actions-metric-grid article {
5959
+  min-width: 0;
5960
+  padding: 1rem;
5961
+  border: 1px solid var(--border-default);
5962
+  border-radius: 6px;
5963
+  background: var(--canvas-default);
5964
+}
5965
+.shithub-actions-metric-grid h2 {
5966
+  margin: 0;
5967
+  font-size: 1rem;
5968
+}
5969
+.shithub-actions-metric-grid strong {
5970
+  display: block;
5971
+  margin-top: 0.55rem;
5972
+  font-size: 1.85rem;
5973
+  line-height: 1.1;
5974
+}
5975
+.shithub-actions-metric-grid p {
5976
+  margin: 0.55rem 0 0;
5977
+  color: var(--fg-muted);
5978
+  font-size: 0.88rem;
5979
+}
5980
+.shithub-actions-management-tabs {
5981
+  display: flex;
5982
+  align-items: center;
5983
+  gap: 0.15rem;
5984
+  margin-bottom: 1rem;
5985
+  border-bottom: 1px solid var(--border-default);
5986
+}
5987
+.shithub-actions-management-tabs span {
5988
+  display: inline-flex;
5989
+  align-items: center;
5990
+  gap: 0.45rem;
5991
+  min-height: 2.6rem;
5992
+  padding: 0 0.75rem;
5993
+  border-bottom: 2px solid transparent;
5994
+  color: var(--fg-muted);
5995
+  font-weight: 600;
5996
+}
5997
+.shithub-actions-management-tabs span.is-active {
5998
+  border-bottom-color: var(--accent-emphasis);
5999
+  color: var(--fg-default);
6000
+}
6001
+.shithub-actions-management-tabs span.is-disabled {
6002
+  cursor: not-allowed;
6003
+}
6004
+.shithub-actions-management-filter {
6005
+  display: flex;
6006
+  justify-content: flex-end;
6007
+  margin-bottom: 1rem;
6008
+}
6009
+.shithub-actions-management-filter label {
6010
+  display: flex;
6011
+  align-items: center;
6012
+  gap: 0.45rem;
6013
+  width: min(24rem, 100%);
6014
+  min-height: 2.2rem;
6015
+  padding: 0.35rem 0.55rem;
6016
+  border: 1px solid var(--border-default);
6017
+  border-radius: 6px;
6018
+  color: var(--fg-muted);
6019
+  background: var(--canvas-default);
6020
+}
6021
+.shithub-actions-management-filter input {
6022
+  width: 100%;
6023
+  min-width: 0;
6024
+  border: 0;
6025
+  outline: 0;
6026
+  background: transparent;
6027
+  color: var(--fg-muted);
6028
+  font: inherit;
6029
+}
6030
+.shithub-actions-management-box {
6031
+  overflow: hidden;
6032
+  border: 1px solid var(--border-default);
6033
+  border-radius: 6px;
6034
+  background: var(--canvas-default);
6035
+}
6036
+.shithub-actions-management-box > header {
6037
+  display: flex;
6038
+  align-items: center;
6039
+  justify-content: space-between;
6040
+  min-height: 3.5rem;
6041
+  padding: 0 1rem;
6042
+  border-bottom: 1px solid var(--border-default);
6043
+  background: var(--canvas-subtle);
6044
+}
6045
+.shithub-actions-management-empty {
6046
+  display: grid;
6047
+  justify-items: center;
6048
+  gap: 0.55rem;
6049
+  min-height: 13rem;
6050
+  padding: 3rem 1rem;
6051
+  text-align: center;
6052
+}
6053
+.shithub-actions-management-empty h2 {
6054
+  margin: 0;
6055
+  font-size: 1.35rem;
6056
+}
6057
+.shithub-actions-management-empty p {
6058
+  margin: 0;
6059
+  max-width: 40rem;
6060
+  color: var(--fg-muted);
6061
+}
59036062
 .shithub-actions-run-page {
59046063
   max-width: 1280px;
59056064
   margin: 0 auto;
@@ -6249,10 +6408,20 @@ button.shithub-repo-action {
62496408
   .shithub-actions-page,
62506409
   .shithub-actions-run-layout,
62516410
   .shithub-actions-filters,
6411
+  .shithub-actions-metric-grid,
62526412
   .shithub-actions-run-row,
62536413
   .shithub-actions-summary-strip {
62546414
     grid-template-columns: 1fr;
62556415
   }
6416
+  .shithub-actions-management-tabs {
6417
+    flex-wrap: wrap;
6418
+  }
6419
+  .shithub-actions-management-filter {
6420
+    justify-content: stretch;
6421
+  }
6422
+  .shithub-actions-management-filter label {
6423
+    width: 100%;
6424
+  }
62566425
   .shithub-actions-run-meta,
62576426
   .shithub-actions-run-head {
62586427
     justify-content: flex-start;
internal/web/templates/repo/_actions_sidebar.htmladded
@@ -0,0 +1,35 @@
1
+{{ define "actions-sidebar" -}}
2
+<aside class="shithub-actions-sidebar" aria-label="Actions navigation">
3
+  <div class="shithub-actions-sidebar-head">
4
+    <h2>Actions</h2>
5
+    <span class="shithub-button shithub-button-primary shithub-actions-new-workflow is-disabled" aria-disabled="true" title="Workflow creation UI is coming later">New workflow</span>
6
+  </div>
7
+  <nav>
8
+    <a href="{{ .ActionsSidebar.AllHref }}" class="shithub-actions-nav-item{{ if .ActionsSidebar.AllActive }} is-active{{ end }}"{{ if .ActionsSidebar.AllActive }} aria-current="page"{{ end }}>
9
+      <span>All workflows</span>
10
+    </a>
11
+    <div class="shithub-actions-sidebar-section">
12
+      <h3>Workflows</h3>
13
+      {{ if .ActionsSidebar.Workflows }}
14
+        {{ range .ActionsSidebar.Workflows }}
15
+          <a href="{{ .Href }}" class="shithub-actions-nav-item{{ if .Active }} is-active{{ end }}"{{ if .Active }} aria-current="page"{{ end }}>
16
+            <span>{{ .Name }}</span>
17
+            <span class="shithub-actions-pin-placeholder" aria-hidden="true">{{ octicon "pin" }}</span>
18
+          </a>
19
+        {{ end }}
20
+      {{ else }}
21
+        <p>No workflows have run yet.</p>
22
+      {{ end }}
23
+    </div>
24
+    <div class="shithub-actions-sidebar-section">
25
+      <h3>Management</h3>
26
+      {{ range .ActionsSidebar.Management }}
27
+        <a href="{{ .Href }}" class="shithub-actions-nav-item shithub-actions-nav-item-visual{{ if .Active }} is-active{{ end }}"{{ if .Active }} aria-current="page"{{ end }}>
28
+          <span class="shithub-actions-nav-visual">{{ octicon .Icon }}</span>
29
+          <span>{{ .Label }}</span>
30
+        </a>
31
+      {{ end }}
32
+    </div>
33
+  </nav>
34
+</aside>
35
+{{- end }}
internal/web/templates/repo/actions.htmlmodified
@@ -1,25 +1,7 @@
11
 {{ define "page" -}}
22
 {{ template "repo-header" . }}
33
 <section class="shithub-actions-page">
4
-  <aside class="shithub-actions-sidebar" aria-label="Actions navigation">
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>
7
-      <span class="shithub-actions-nav-count">{{ .RunCount }}</span>
8
-    </a>
9
-    <div class="shithub-actions-sidebar-section">
10
-      <h2>Workflows</h2>
11
-      {{ if .Workflows }}
12
-        {{ range .Workflows }}
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>
15
-            <span class="shithub-actions-nav-count">{{ .Count }}</span>
16
-          </a>
17
-        {{ end }}
18
-      {{ else }}
19
-        <p>No workflows have run yet.</p>
20
-      {{ end }}
21
-    </div>
22
-  </aside>
4
+  {{ template "actions-sidebar" . }}
235
 
246
   <div class="shithub-actions-main">
257
     <header class="shithub-actions-head">
@@ -68,7 +50,6 @@
6850
             </div>
6951
           </details>
7052
         {{ end }}
71
-        <a class="shithub-button" href="/{{ .Owner }}/{{ .Repo.Name }}/settings/secrets/actions">{{ octicon "gear" }} Actions settings</a>
7253
       </div>
7354
     </header>
7455
 
internal/web/templates/repo/actions_management.htmladded
@@ -0,0 +1,65 @@
1
+{{ define "page" -}}
2
+{{ template "repo-header" . }}
3
+<section class="shithub-actions-page">
4
+  {{ template "actions-sidebar" . }}
5
+
6
+  <div class="shithub-actions-main">
7
+    <header class="shithub-actions-head shithub-actions-management-head">
8
+      <div>
9
+        <h1>{{ .Page.Title }}</h1>
10
+        <p>{{ .Page.Description }}</p>
11
+      </div>
12
+      {{ if .Page.PrimaryAction }}
13
+        {{ if .Page.PrimaryDisabled }}
14
+          <span class="shithub-button shithub-button-primary shithub-button-disabled" aria-disabled="true">{{ .Page.PrimaryAction }}</span>
15
+        {{ else }}
16
+          <a class="shithub-button shithub-button-primary" href="#">{{ .Page.PrimaryAction }}</a>
17
+        {{ end }}
18
+      {{ end }}
19
+    </header>
20
+
21
+    {{ if .Page.Stats }}
22
+      <section class="shithub-actions-metric-grid" aria-label="{{ .Page.Title }} summary">
23
+        {{ range .Page.Stats }}
24
+          <article>
25
+            <h2>{{ .Label }}</h2>
26
+            <strong>{{ .Value }}</strong>
27
+            <p>{{ .Note }}</p>
28
+          </article>
29
+        {{ end }}
30
+      </section>
31
+    {{ end }}
32
+
33
+    {{ if .Page.Tabs }}
34
+      <nav class="shithub-actions-management-tabs" aria-label="{{ .Page.Title }} views">
35
+        {{ range .Page.Tabs }}
36
+          <span class="{{ if .Active }}is-active{{ else }}is-disabled{{ end }}"{{ if .Active }} aria-current="page"{{ else }} aria-disabled="true"{{ end }}>
37
+            {{ octicon .Icon }} {{ .Label }}
38
+          </span>
39
+        {{ end }}
40
+      </nav>
41
+    {{ end }}
42
+
43
+    {{ if .Page.SearchPlaceholder }}
44
+      <form class="shithub-actions-management-filter" role="search" aria-label="{{ .Page.SearchPlaceholder }}">
45
+        <label>
46
+          {{ octicon "search" }}
47
+          <span class="sr-only">{{ .Page.SearchPlaceholder }}</span>
48
+          <input type="search" placeholder="{{ .Page.SearchPlaceholder }}" disabled>
49
+        </label>
50
+      </form>
51
+    {{ end }}
52
+
53
+    <section class="shithub-actions-management-box">
54
+      <header>
55
+        <strong>{{ .Page.CountLabel }}</strong>
56
+      </header>
57
+      <div class="shithub-actions-management-empty">
58
+        <span class="shithub-actions-empty-icon">{{ octicon .Page.Icon }}</span>
59
+        <h2>{{ .Page.EmptyTitle }}</h2>
60
+        <p>{{ .Page.EmptyBody }}</p>
61
+      </div>
62
+    </section>
63
+  </div>
64
+</section>
65
+{{- end }}