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 {"engine_socket", "mount the Docker/Podman socket for image builds; the server must set CI_ENGINE_SOCKET=1"},
131 {"timeout", "per-step timeout in seconds"},
132}
133
134var ciArtifactTypes = [][2]string{
135 {"publish_file", "copy file as-is"},
136 {"publish_tar", ".tar archive"},
137 {"publish_gzip", ".tar.gz archive"},
138 {"publish_zstd", ".tar.zst archive"},
139 {"publish_zip", ".zip archive"},
140}
141
142// ciHelp is the collapsible tutorial below the run list.
143func ciHelp(repo *db.Repo) g.Node {
144 return Details(Class("form-card ci-help"),
145 Summary(Class("ci-help-summary"),
146 Span(g.Text("How to define a pipeline file")),
147 A(Href("/assets/hearthforge-ci-template.toml"), Download(".hearthforge-ci.toml"),
148 Class("btn btn-sm btn-secondary ci-help-download"), g.Text("Download template")),
149 ),
150 Div(Class("ci-help-body"),
151 P(Class("ci-help-desc"),
152 g.Text("Add "), Code(g.Text(".hearthforge-ci.toml")),
153 g.Text(" to your repository root. Each "), Code(g.Text("[[steps]]")),
154 g.Text(" entry is a step, executed in file order, and needs a "),
155 Code(g.Text("name")), g.Text(". Other tables: "), Code(g.Text("[on]")),
156 g.Text(" (triggers), "), Code(g.Text("[variables]")),
157 g.Text(" (user-overridable inputs) and "), Code(g.Text("[[copy]]")),
158 g.Text(" (files taken from another image, like "), Code(g.Text("COPY --from")),
159 g.Text(")."),
160 ),
161 Div(Class("ci-help-sections"),
162 Div(Class("ci-help-section"),
163 H4(Class("ci-help-section-title"), g.Text("Predefined variables")),
164 ciDefList(ciPredefinedVariables),
165 ),
166 Div(Class("ci-help-section"),
167 H4(Class("ci-help-section-title"), g.Text("Status badge")),
168 P(Class("ci-help-badge-desc"), g.Text("Embed in your README:")),
169 Code(Class("ci-help-badge-code"),
170 g.Text("![pipeline](/"+repo.Name+"/ci/badge.svg)")),
171 H4(Class("ci-help-section-title"), Style("margin-top: var(--space-4)"),
172 g.Text("Step options")),
173 ciDefList(ciStepOptions),
174 H4(Class("ci-help-section-title"), Style("margin-top: var(--space-4)"),
175 g.Text("Artifact types")),
176 ciDefList(ciArtifactTypes),
177 ),
178 ),
179 ),
180 )
181}
182
183// ciRunPipeline renders the manual trigger. With no declared variables it is a
184// plain submit button. With variables it opens a panel of inputs seeded with
185// their defaults, so a field left untouched sends the default back and counts
186// as no override.
187func ciRunPipeline(repo *db.Repo, disabledReason string, variables []CiVariable) g.Node {
188 form := Form(Method("POST"), Action("/"+repo.Name+"/ci/run"),
189 g.If(len(variables) > 0, Div(Class("ci-run-vars"),
190 g.Map(variables, func(v CiVariable) g.Node {
191 return Label(Class("form-group ci-run-var"),
192 Code(g.Text(v.Name)),
193 g.If(v.Description != "",
194 Span(Class("ci-run-var-desc"), g.Text(v.Description))),
195 Input(Type("text"), Name("var_"+v.Name), Value(v.Default),
196 AutoComplete("off")),
197 )
198 }),
199 )),
200 Button(Type("submit"), Class("btn btn-primary btn-sm"),
201 g.If(disabledReason != "", Disabled()),
202 g.If(disabledReason != "", Title(disabledReason)),
203 g.Text("Run pipeline")),
204 )
205
206 if len(variables) == 0 {
207 return Div(Class("inline-form"), form)
208 }
209 return Details(Class("confirm-details ci-run-details"),
210 Summary(Class("btn btn-primary btn-sm"),
211 g.If(disabledReason != "", Title(disabledReason)), g.Text("Run pipeline")),
212 Div(Class("confirm-popup ci-run-popup"), form),
213 )
214}
215
216// CiHistory renders the Pipelines tab: the run list, the manual trigger and
217// the pipeline file tutorial.
218func CiHistory(cfg *config.Config, user *db.SessionUser, repo *db.Repo, runs []CiRunSummary,
219 page PageInfo, manualTriggerDisabledReason string, variables []CiVariable,
220 success, errMsg string,
221) g.Node {
222 isRunning := false
223 for _, r := range runs {
224 if ciIsActive(r.Run.Status) {
225 isRunning = true
226 break
227 }
228 }
229 return Layout(Page{Title: "Pipelines — " + repo.Name, User: user, Cfg: cfg},
230 g.If(isRunning, Meta(g.Attr("http-equiv", "refresh"), Content("4"))),
231 Div(Class("container"),
232 RepoHeader(repo),
233 RepoNav(cfg, repo, "ci", user),
234 g.If(success != "", P(Class("form-success"), g.Text(success))),
235 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
236 Div(Class("list-header"),
237 H2(Class("list-heading"), g.Text("Pipelines")),
238 g.If(user != nil && user.IsAdmin, Div(Class("list-header-actions"),
239 Form(Method("POST"), Action("/"+repo.Name+"/ci/purge-cache"),
240 Class("inline-form"),
241 Button(Type("submit"), Class("btn btn-secondary btn-sm"),
242 Title("Delete all Docker cache volumes for this repository"),
243 g.Text("Purge caches")),
244 ),
245 ciRunPipeline(repo, manualTriggerDisabledReason, variables),
246 )),
247 ),
248 g.If(len(runs) == 0, Div(Class("empty-state"),
249 P(g.Text("No pipeline runs yet.")),
250 P(Class("text-muted"), g.Text("Push a "), Code(g.Text(".hearthforge-ci.toml")),
251 g.Text(" to your repository to get started.")),
252 )),
253 g.If(len(runs) > 0, Ul(Class("issue-list"),
254 g.Map(runs, func(r CiRunSummary) g.Node { return ciRunItem(repo, r) }),
255 )),
256 Pagination(page),
257 ciHelp(repo),
258 ),
259 )
260}
261
262// ciRunItem renders one entry of the run list.
263func ciRunItem(repo *db.Repo, r CiRunSummary) g.Node {
264 run := r.Run
265 title := ""
266 if run.Status == "queued" {
267 title = ciQueueTitle(r.QueuePosition)
268 }
269 shortSHA := ""
270 if run.CommitSHA != nil && len(*run.CommitSHA) >= 8 {
271 shortSHA = (*run.CommitSHA)[:8]
272 } else if run.CommitSHA != nil {
273 shortSHA = *run.CommitSHA
274 }
275 artifactText := strconv.Itoa(r.ArtifactCount) + " artifact"
276 if r.ArtifactCount != 1 {
277 artifactText += "s"
278 }
279 return Li(Class("issue-item"),
280 Div(Class("release-item-header"),
281 Div(Class("release-item-main"),
282 A(Href("/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10)),
283 Class("release-item-title"),
284 ciStatusPill(run.Status, title),
285 Span(Class("ci-run-id"), g.Text("#"+ciRunNumber(&run))),
286 ),
287 Div(Class("release-item-meta"),
288 g.If(shortSHA != "", Code(Class("ci-sha"), g.Text(shortSHA))),
289 g.If(deref(run.CommitBranch) != "",
290 Span(Class("badge"), g.Text(deref(run.CommitBranch)))),
291 g.If(deref(run.CommitTag) != "",
292 Span(Class("badge"), g.Text(deref(run.CommitTag)))),
293 Span(Class("text-muted"), g.Text(run.TriggerSource)),
294 g.If(deref(run.TriggeredByUsername) != "",
295 Span(Class("text-muted"), g.Text("by "+deref(run.TriggeredByUsername)))),
296 g.If(r.ArtifactCount > 0, Span(g.Text(artifactText))),
297 g.If(ciDuration(run.StartedAt, run.FinishedAt) != "",
298 Span(Class("text-muted"),
299 g.Text(ciDuration(run.StartedAt, run.FinishedAt)))),
300 ),
301 ),
302 Div(Class("release-item-date"),
303 Time(DateTime(run.CreatedAt), g.Text(util.FormatDateTime(run.CreatedAt))),
304 ),
305 ),
306 )
307}
308
309// ciOverrides decodes the variable overrides stored on the run. A corrupted
310// value renders as no overrides.
311func ciOverrides(raw *string) []CiVariable {
312 if raw == nil || *raw == "" {
313 return nil
314 }
315 var m map[string]string
316 if err := json.Unmarshal([]byte(*raw), &m); err != nil {
317 return nil
318 }
319 names := make([]string, 0, len(m))
320 for k := range m {
321 names = append(names, k)
322 }
323 // The JSON object has no order, so show the names alphabetically.
324 sort.Strings(names)
325 out := make([]CiVariable, 0, len(names))
326 for _, n := range names {
327 out = append(out, CiVariable{Name: n, Default: m[n]})
328 }
329 return out
330}
331
332// CiRunDetail renders one pipeline run with its steps, logs and artifacts.
333func CiRunDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, run *db.CiRun,
334 steps []db.CiStep, artifacts []db.CiArtifact, autoRefresh bool, queuePosition int,
335) g.Node {
336 isActive := ciIsActive(run.Status)
337 isQueued := run.Status == "queued"
338 queueText := ciQueueTitle(queuePosition)
339 displayID := ciRunNumber(run)
340 overrides := ciOverrides(run.VariableOverrides)
341 runPath := "/" + repo.Name + "/ci/" + strconv.FormatInt(run.ID, 10)
342
343 pillTitle := ""
344 if isQueued {
345 pillTitle = queueText
346 }
347 shortSHA := ""
348 if run.CommitSHA != nil {
349 shortSHA = *run.CommitSHA
350 if len(shortSHA) > 8 {
351 shortSHA = shortSHA[:8]
352 }
353 }
354 triggeredBy := "triggered by " + run.TriggerSource
355 if deref(run.TriggeredByUsername) != "" {
356 triggeredBy += " (" + deref(run.TriggeredByUsername) + ")"
357 }
358 refreshHref := "?"
359 refreshLabel := "Resume refresh"
360 if autoRefresh {
361 refreshHref = "?refresh=off"
362 refreshLabel = "Pause refresh"
363 }
364
365 return Layout(Page{Title: "Pipeline #" + displayID + " — " + repo.Name, User: user, Cfg: cfg},
366 g.If(isActive && autoRefresh, Meta(g.Attr("http-equiv", "refresh"), Content("3"))),
367 Div(Class("container"),
368 RepoHeader(repo),
369 RepoNav(cfg, repo, "ci", user),
370
371 Div(Class("ci-run-header"),
372 Div(
373 H2(Class("release-detail-title"),
374 ciStatusPill(run.Status, pillTitle),
375 g.Text(" Pipeline #"+displayID),
376 ),
377 Div(Class("release-item-meta"),
378 g.If(shortSHA != "", Code(Class("ci-sha"), g.Text(shortSHA))),
379 g.If(deref(run.CommitBranch) != "",
380 A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(run.CommitBranch))), Class("badge"),
381 g.Text(deref(run.CommitBranch)))),
382 g.If(deref(run.CommitTag) != "",
383 A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(run.CommitTag))), Class("badge"),
384 g.Text(deref(run.CommitTag)))),
385 Span(Class("text-muted"), g.Text(triggeredBy)),
386 g.If(ciDuration(run.StartedAt, run.FinishedAt) != "",
387 Span(Class("text-muted"),
388 g.Text(ciDuration(run.StartedAt, run.FinishedAt)))),
389 Time(DateTime(run.CreatedAt), Class("text-muted"),
390 g.Text(util.FormatDateTime(run.CreatedAt))),
391 ),
392 ),
393 Div(Class("ci-run-actions"),
394 g.If(isActive, A(Href(refreshHref), Class("btn btn-secondary"),
395 g.Text(refreshLabel))),
396 g.If(user != nil && user.IsAdmin && isActive,
397 Form(Method("POST"), Action(runPath+"/cancel"), Class("inline-form"),
398 Button(Type("submit"), Class("btn btn-danger"), g.Text("Cancel")),
399 )),
400 g.If(user != nil && user.IsAdmin && !isActive,
401 Form(Method("POST"), Action(runPath+"/retry"), Class("inline-form"),
402 Button(Type("submit"), Class("btn btn-secondary"),
403 Title("Re-run with the same commit, trigger source, and variable overrides"),
404 g.Text("Retry")),
405 )),
406 ),
407 ),
408
409 g.If(len(overrides) > 0, Div(Class("form-card ci-overrides"),
410 H3(Class("section-title"), g.Text("Variable overrides")),
411 Dl(Class("ci-vars-list"), g.Map(overrides, func(v CiVariable) g.Node {
412 return g.Group{Dt(Code(g.Text(v.Name))), Dd(g.Text(v.Default))}
413 })),
414 )),
415
416 Div(Class("ci-steps"),
417 H3(Class("section-title"), g.Text("Steps")),
418 g.If(len(steps) == 0, Div(Class("ci-step-pending"),
419 Span(Class("text-muted"), g.Text(func() string {
420 if isQueued {
421 return queueText
422 }
423 return "Waiting to start…"
424 }())),
425 )),
426 g.Map(steps, func(step db.CiStep) g.Node { return ciStepNode(step) }),
427 ),
428
429 g.If(len(artifacts) > 0, Div(Class("form-card ci-artifacts"),
430 H3(Class("section-title"), g.Text("Artifacts")),
431 Ul(Class("ci-artifact-list"), g.Map(artifacts, func(a db.CiArtifact) g.Node {
432 return Li(Class("ci-artifact-item"),
433 A(Href(runPath+"/artifacts/"+strconv.FormatInt(a.ID, 10)),
434 Class("ci-artifact-name"), g.Text(a.Filename)),
435 Span(Class("ci-artifact-size text-muted"), g.Text(util.FormatBytes(a.Size))),
436 )
437 })),
438 )),
439 ),
440 )
441}
442
443// ciStepNode renders one step with its log.
444func ciStepNode(step db.CiStep) g.Node {
445 dur := ciDuration(step.StartedAt, step.FinishedAt)
446 return Details(Class("ci-step ci-step-"+step.Status),
447 g.If(step.Status == "running", Open()),
448 Summary(Class("ci-step-summary"),
449 ciStatusPill(step.Status, ""),
450 Span(Class("ci-step-name"), g.Text(step.Name)),
451 g.If(dur != "", Span(Class("ci-step-duration text-muted"), g.Text(dur))),
452 ),
453 g.If(step.Log != "", Pre(Class("ci-step-log"), g.Text(step.Log))),
454 g.If(step.Log == "" && step.Status == "running",
455 Div(Class("ci-step-running-indicator text-muted"), g.Text("Running…"))),
456 )
457}
458