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