ci.go
⎇
Raw
1package views
2
3import (
4 "encoding/json"
5 "sort"
6 "strconv"
7 "time"
8
9 "hearthforge/internal/config"
10 "hearthforge/internal/db"
11 "hearthforge/internal/util"
12
13 g "maragu.dev/gomponents"
14 . "maragu.dev/gomponents/html"
15)
16
17// ciStatusClasses maps a run or step status to its pill class.
18var ciStatusClasses = map[string]string{
19 "pending": "ci-status-pending",
20 "queued": "ci-status-queued",
21 "running": "ci-status-running",
22 "success": "ci-status-success",
23 "failure": "ci-status-failure",
24 "warning": "ci-status-warning",
25 "cancelled": "ci-status-cancelled",
26 "skipped": "ci-status-skipped",
27}
28
29// ciStatusPill adds the tooltip the queued state uses.
30func ciStatusPill(status, title string) g.Node {
31 cls := ciStatusClasses[status]
32 if cls == "" {
33 cls = "ci-status-pending"
34 }
35 return Span(Class("ci-status-pill "+cls), g.If(title != "", Title(title)), g.Text(status))
36}
37
38// CiVariable is one [variables] entry of .hearthforge-ci.toml, in file order.
39type CiVariable struct {
40 Name string
41 Default string
42 Description string
43}
44
45// CiRunSummary is one row of the history list.
46type CiRunSummary struct {
47 Run db.CiRun
48 ArtifactCount int
49 // QueuePosition is 1-based, 0 when the run does not wait in the queue.
50 QueuePosition int
51}
52
53// ciQueueTitle explains the queued state in the pill tooltip.
54func ciQueueTitle(position int) string {
55 switch {
56 case position <= 0:
57 return "Waiting in the build queue"
58 case position == 1:
59 return "Waiting in the build queue — next up"
60 case position == 2:
61 return "Waiting in the build queue — 1 run ahead"
62 default:
63 return "Waiting in the build queue — " + strconv.Itoa(position-1) + " runs ahead"
64 }
65}
66
67// ciDuration renders how long a run or step took. It returns "" when either
68// timestamp is missing or unparsable.
69func ciDuration(start, end *string) string {
70 if start == nil || end == nil {
71 return ""
72 }
73 from, err1 := time.Parse(time.RFC3339Nano, *start)
74 to, err2 := time.Parse(time.RFC3339Nano, *end)
75 if err1 != nil || err2 != nil {
76 return ""
77 }
78 s := int(to.Sub(from).Seconds())
79 if s < 0 {
80 return ""
81 }
82 if s < 60 {
83 return strconv.Itoa(s) + "s"
84 }
85 m, rem := s/60, s%60
86 if rem > 0 {
87 return strconv.Itoa(m) + "m " + strconv.Itoa(rem) + "s"
88 }
89 return strconv.Itoa(m) + "m"
90}
91
92// ciRunNumber is the per-repo run number, falling back to the row id.
93func ciRunNumber(run *db.CiRun) string {
94 if run.RepoRunID != nil {
95 return strconv.FormatInt(*run.RepoRunID, 10)
96 }
97 return strconv.FormatInt(run.ID, 10)
98}
99
100func ciIsActive(status string) bool {
101 return status == "pending" || status == "running" || status == "queued"
102}
103
104// ciDefList renders a name/description list of the help section.
105func ciDefList(pairs [][2]string) g.Node {
106 return Dl(Class("ci-help-vars"), g.Map(pairs, func(p [2]string) g.Node {
107 return g.Group{Dt(Code(g.Text(p[0]))), Dd(g.Text(p[1]))}
108 }))
109}
110
111var ciPredefinedVariables = [][2]string{
112 {"CI", "always true"},
113 {"CI_PIPELINE_ID", "numeric run ID"},
114 {"CI_COMMIT_SHA", "full commit hash"},
115 {"CI_COMMIT_SHORT_SHA", "first 8 chars"},
116 {"CI_COMMIT_BRANCH", "branch name (empty for tags)"},
117 {"CI_COMMIT_TAG", "tag name (empty for branches)"},
118 {"CI_COMMIT_REF_NAME", "branch or tag name"},
119 {"CI_TRIGGER_SOURCE", "push · tag · manual"},
120 {"CI_REPO_NAME", "repository name"},
121 {"CI_SERVER_URL", "HearthForge base URL"},
122}
123
124var ciStepOptions = [][2]string{
125 {"run_sh", "shell command to run"},
126 {"run_if", "skip the step unless this exits 0"},
127 {"always", "run even after an earlier step failed"},
128 {"warn_on_fail", "step warns instead of failing; the run reports warning"},
129 {"clear", "re-extract a clean checkout before the step"},
130 {"timeout", "per-step timeout in seconds"},
131}
132
133var ciArtifactTypes = [][2]string{
134 {"publish_file", "copy file as-is"},
135 {"publish_tar", ".tar archive"},
136 {"publish_gzip", ".tar.gz archive"},
137 {"publish_zstd", ".tar.zst archive"},
138 {"publish_zip", ".zip archive"},
139}
140
141// ciHelp is the collapsible tutorial below the run list.
142func ciHelp(repo *db.Repo) g.Node {
143 return Details(Class("form-card ci-help"),
144 Summary(Class("ci-help-summary"),
145 Span(g.Text("How to define a pipeline file")),
146 A(Href("/assets/hearthforge-ci-template.toml"), Download(".hearthforge-ci.toml"),
147 Class("btn btn-sm btn-secondary ci-help-download"), g.Text("Download template")),
148 ),
149 Div(Class("ci-help-body"),
150 P(Class("ci-help-desc"),
151 g.Text("Add "), Code(g.Text(".hearthforge-ci.toml")),
152 g.Text(" to your repository root. Each "), Code(g.Text("[[steps]]")),
153 g.Text(" entry is a step, executed in file order, and needs a "),
154 Code(g.Text("name")), g.Text(". Other tables: "), Code(g.Text("[on]")),
155 g.Text(" (triggers), "), Code(g.Text("[variables]")),
156 g.Text(" (user-overridable inputs) and "), Code(g.Text("[[copy]]")),
157 g.Text(" (files taken from another image, like "), Code(g.Text("COPY --from")),
158 g.Text(")."),
159 ),
160 Div(Class("ci-help-sections"),
161 Div(Class("ci-help-section"),
162 H4(Class("ci-help-section-title"), g.Text("Predefined variables")),
163 ciDefList(ciPredefinedVariables),
164 ),
165 Div(Class("ci-help-section"),
166 H4(Class("ci-help-section-title"), g.Text("Status badge")),
167 P(Class("ci-help-badge-desc"), g.Text("Embed in your README:")),
168 Code(Class("ci-help-badge-code"),
169 g.Text("![pipeline](/"+repo.Name+"/ci/badge.svg)")),
170 H4(Class("ci-help-section-title"), Style("margin-top: var(--space-4)"),
171 g.Text("Step options")),
172 ciDefList(ciStepOptions),
173 H4(Class("ci-help-section-title"), Style("margin-top: var(--space-4)"),
174 g.Text("Artifact types")),
175 ciDefList(ciArtifactTypes),
176 ),
177 ),
178 ),
179 )
180}
181
182// ciRunPipeline renders the manual trigger. With no declared variables it is a
183// plain submit button. With variables it opens a panel of inputs seeded with
184// their defaults, so a field left untouched sends the default back and counts
185// as no override.
186func ciRunPipeline(repo *db.Repo, disabledReason string, variables []CiVariable) g.Node {
187 form := Form(Method("POST"), Action("/"+repo.Name+"/ci/run"),
188 g.If(len(variables) > 0, Div(Class("ci-run-vars"),
189 g.Map(variables, func(v CiVariable) g.Node {
190 return Label(Class("form-group ci-run-var"),
191 Code(g.Text(v.Name)),
192 g.If(v.Description != "",
193 Span(Class("ci-run-var-desc"), g.Text(v.Description))),
194 Input(Type("text"), Name("var_"+v.Name), Value(v.Default),
195 AutoComplete("off")),
196 )
197 }),
198 )),
199 Button(Type("submit"), Class("btn btn-primary btn-sm"),
200 g.If(disabledReason != "", Disabled()),
201 g.If(disabledReason != "", Title(disabledReason)),
202 g.Text("Run pipeline")),
203 )
204
205 if len(variables) == 0 {
206 return Div(Class("inline-form"), form)
207 }
208 return Details(Class("confirm-details ci-run-details"),
209 Summary(Class("btn btn-primary btn-sm"),
210 g.If(disabledReason != "", Title(disabledReason)), g.Text("Run pipeline")),
211 Div(Class("confirm-popup ci-run-popup"), form),
212 )
213}
214
215// CiHistory renders the Pipelines tab: the run list, the manual trigger and
216// the pipeline file tutorial.
217func CiHistory(cfg *config.Config, user *db.SessionUser, repo *db.Repo, runs []CiRunSummary,
218 page PageInfo, manualTriggerDisabledReason string, variables []CiVariable,
219 success, errMsg string,
220) g.Node {
221 isRunning := false
222 for _, r := range runs {
223 if ciIsActive(r.Run.Status) {
224 isRunning = true
225 break
226 }
227 }
228 return Layout(Page{Title: "Pipelines — " + repo.Name, User: user, Cfg: cfg},
229 g.If(isRunning, Meta(g.Attr("http-equiv", "refresh"), Content("4"))),
230 Div(Class("container"),
231 RepoHeader(repo),
232 RepoNav(repo, "ci", user),
233 g.If(success != "", P(Class("form-success"), g.Text(success))),
234 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
235 Div(Class("list-header"),
236 H2(Class("list-heading"), g.Text("Pipelines")),
237 g.If(user != nil && user.IsAdmin, Div(Class("list-header-actions"),
238 Form(Method("POST"), Action("/"+repo.Name+"/ci/purge-cache"),
239 Class("inline-form"),
240 Button(Type("submit"), Class("btn btn-secondary btn-sm"),
241 Title("Delete all Docker cache volumes for this repository"),
242 g.Text("Purge caches")),
243 ),
244 ciRunPipeline(repo, manualTriggerDisabledReason, variables),
245 )),
246 ),
247 g.If(len(runs) == 0, Div(Class("empty-state"),
248 P(g.Text("No pipeline runs yet.")),
249 P(Class("text-muted"), g.Text("Push a "), Code(g.Text(".hearthforge-ci.toml")),
250 g.Text(" to your repository to get started.")),
251 )),
252 g.If(len(runs) > 0, Ul(Class("issue-list"),
253 g.Map(runs, func(r CiRunSummary) g.Node { return ciRunItem(repo, r) }),
254 )),
255 Pagination(page),
256 ciHelp(repo),
257 ),
258 )
259}
260
261// ciRunItem renders one entry of the run list.
262func ciRunItem(repo *db.Repo, r CiRunSummary) g.Node {
263 run := r.Run
264 title := ""
265 if run.Status == "queued" {
266 title = ciQueueTitle(r.QueuePosition)
267 }
268 shortSHA := ""
269 if run.CommitSHA != nil && len(*run.CommitSHA) >= 8 {
270 shortSHA = (*run.CommitSHA)[:8]
271 } else if run.CommitSHA != nil {
272 shortSHA = *run.CommitSHA
273 }
274 artifactText := strconv.Itoa(r.ArtifactCount) + " artifact"
275 if r.ArtifactCount != 1 {
276 artifactText += "s"
277 }
278 return Li(Class("issue-item"),
279 Div(Class("release-item-header"),
280 Div(Class("release-item-main"),
281 A(Href("/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10)),
282 Class("release-item-title"),
283 ciStatusPill(run.Status, title),
284 Span(Class("ci-run-id"), g.Text("#"+ciRunNumber(&run))),
285 ),
286 Div(Class("release-item-meta"),
287 g.If(shortSHA != "", Code(Class("ci-sha"), g.Text(shortSHA))),
288 g.If(deref(run.CommitBranch) != "",
289 Span(Class("badge"), g.Text(deref(run.CommitBranch)))),
290 g.If(deref(run.CommitTag) != "",
291 Span(Class("badge"), g.Text(deref(run.CommitTag)))),
292 Span(Class("text-muted"), g.Text(run.TriggerSource)),
293 g.If(deref(run.TriggeredByUsername) != "",
294 Span(Class("text-muted"), g.Text("by "+deref(run.TriggeredByUsername)))),
295 g.If(r.ArtifactCount > 0, Span(g.Text(artifactText))),
296 g.If(ciDuration(run.StartedAt, run.FinishedAt) != "",
297 Span(Class("text-muted"),
298 g.Text(ciDuration(run.StartedAt, run.FinishedAt)))),
299 ),
300 ),
301 Div(Class("release-item-date"),
302 Time(DateTime(run.CreatedAt), g.Text(util.FormatDateTime(run.CreatedAt))),
303 ),
304 ),
305 )
306}
307
308// ciOverrides decodes the variable overrides stored on the run. A corrupted
309// value renders as no overrides.
310func ciOverrides(raw *string) []CiVariable {
311 if raw == nil || *raw == "" {
312 return nil
313 }
314 var m map[string]string
315 if err := json.Unmarshal([]byte(*raw), &m); err != nil {
316 return nil
317 }
318 names := make([]string, 0, len(m))
319 for k := range m {
320 names = append(names, k)
321 }
322 // The JSON object has no order, so show the names alphabetically.
323 sort.Strings(names)
324 out := make([]CiVariable, 0, len(names))
325 for _, n := range names {
326 out = append(out, CiVariable{Name: n, Default: m[n]})
327 }
328 return out
329}
330
331// CiRunDetail renders one pipeline run with its steps, logs and artifacts.
332func CiRunDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, run *db.CiRun,
333 steps []db.CiStep, artifacts []db.CiArtifact, autoRefresh bool, queuePosition int,
334) g.Node {
335 isActive := ciIsActive(run.Status)
336 isQueued := run.Status == "queued"
337 queueText := ciQueueTitle(queuePosition)
338 displayID := ciRunNumber(run)
339 overrides := ciOverrides(run.VariableOverrides)
340 runPath := "/" + repo.Name + "/ci/" + strconv.FormatInt(run.ID, 10)
341
342 pillTitle := ""
343 if isQueued {
344 pillTitle = queueText
345 }
346 shortSHA := ""
347 if run.CommitSHA != nil {
348 shortSHA = *run.CommitSHA
349 if len(shortSHA) > 8 {
350 shortSHA = shortSHA[:8]
351 }
352 }
353 triggeredBy := "triggered by " + run.TriggerSource
354 if deref(run.TriggeredByUsername) != "" {
355 triggeredBy += " (" + deref(run.TriggeredByUsername) + ")"
356 }
357 refreshHref := "?"
358 refreshLabel := "Resume refresh"
359 if autoRefresh {
360 refreshHref = "?refresh=off"
361 refreshLabel = "Pause refresh"
362 }
363
364 return Layout(Page{Title: "Pipeline #" + displayID + " — " + repo.Name, User: user, Cfg: cfg},
365 g.If(isActive && autoRefresh, Meta(g.Attr("http-equiv", "refresh"), Content("3"))),
366 Div(Class("container"),
367 RepoHeader(repo),
368 RepoNav(repo, "ci", user),
369
370 Div(Class("ci-run-header"),
371 Div(
372 H2(Class("release-detail-title"),
373 ciStatusPill(run.Status, pillTitle),
374 g.Text(" Pipeline #"+displayID),
375 ),
376 Div(Class("release-item-meta"),
377 g.If(shortSHA != "", Code(Class("ci-sha"), g.Text(shortSHA))),
378 g.If(deref(run.CommitBranch) != "",
379 A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(run.CommitBranch))), Class("badge"),
380 g.Text(deref(run.CommitBranch)))),
381 g.If(deref(run.CommitTag) != "",
382 A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(run.CommitTag))), Class("badge"),
383 g.Text(deref(run.CommitTag)))),
384 Span(Class("text-muted"), g.Text(triggeredBy)),
385 g.If(ciDuration(run.StartedAt, run.FinishedAt) != "",
386 Span(Class("text-muted"),
387 g.Text(ciDuration(run.StartedAt, run.FinishedAt)))),
388 Time(DateTime(run.CreatedAt), Class("text-muted"),
389 g.Text(util.FormatDateTime(run.CreatedAt))),
390 ),
391 ),
392 Div(Class("ci-run-actions"),
393 g.If(isActive, A(Href(refreshHref), Class("btn btn-secondary"),
394 g.Text(refreshLabel))),
395 g.If(user != nil && user.IsAdmin && isActive,
396 Form(Method("POST"), Action(runPath+"/cancel"), Class("inline-form"),
397 Button(Type("submit"), Class("btn btn-danger"), g.Text("Cancel")),
398 )),
399 g.If(user != nil && user.IsAdmin && !isActive,
400 Form(Method("POST"), Action(runPath+"/retry"), Class("inline-form"),
401 Button(Type("submit"), Class("btn btn-secondary"),
402 Title("Re-run with the same commit, trigger source, and variable overrides"),
403 g.Text("Retry")),
404 )),
405 ),
406 ),
407
408 g.If(len(overrides) > 0, Div(Class("form-card ci-overrides"),
409 H3(Class("section-title"), g.Text("Variable overrides")),
410 Dl(Class("ci-vars-list"), g.Map(overrides, func(v CiVariable) g.Node {
411 return g.Group{Dt(Code(g.Text(v.Name))), Dd(g.Text(v.Default))}
412 })),
413 )),
414
415 Div(Class("ci-steps"),
416 H3(Class("section-title"), g.Text("Steps")),
417 g.If(len(steps) == 0, Div(Class("ci-step-pending"),
418 Span(Class("text-muted"), g.Text(func() string {
419 if isQueued {
420 return queueText
421 }
422 return "Waiting to start…"
423 }())),
424 )),
425 g.Map(steps, func(step db.CiStep) g.Node { return ciStepNode(step) }),
426 ),
427
428 g.If(len(artifacts) > 0, Div(Class("form-card ci-artifacts"),
429 H3(Class("section-title"), g.Text("Artifacts")),
430 Ul(Class("ci-artifact-list"), g.Map(artifacts, func(a db.CiArtifact) g.Node {
431 return Li(Class("ci-artifact-item"),
432 A(Href(runPath+"/artifacts/"+strconv.FormatInt(a.ID, 10)),
433 Class("ci-artifact-name"), g.Text(a.Filename)),
434 Span(Class("ci-artifact-size text-muted"), g.Text(util.FormatBytes(a.Size))),
435 )
436 })),
437 )),
438 ),
439 )
440}
441
442// ciStepNode renders one step with its log.
443func ciStepNode(step db.CiStep) g.Node {
444 dur := ciDuration(step.StartedAt, step.FinishedAt)
445 return Details(Class("ci-step ci-step-"+step.Status),
446 g.If(step.Status == "running", Open()),
447 Summary(Class("ci-step-summary"),
448 ciStatusPill(step.Status, ""),
449 Span(Class("ci-step-name"), g.Text(step.Name)),
450 g.If(dur != "", Span(Class("ci-step-duration text-muted"), g.Text(dur))),
451 ),
452 g.If(step.Log != "", Pre(Class("ci-step-log"), g.Text(step.Log))),
453 g.If(step.Log == "" && step.Status == "running",
454 Div(Class("ci-step-running-indicator text-muted"), g.Text("Running…"))),
455 )
456}
457