package views import ( "encoding/json" "sort" "strconv" "time" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // ciStatusClasses maps a run or step status to its pill class. var ciStatusClasses = map[string]string{ "pending": "ci-status-pending", "queued": "ci-status-queued", "running": "ci-status-running", "success": "ci-status-success", "failure": "ci-status-failure", "warning": "ci-status-warning", "cancelled": "ci-status-cancelled", "skipped": "ci-status-skipped", } // ciStatusPill adds the tooltip the queued state uses. func ciStatusPill(status, title string) g.Node { cls := ciStatusClasses[status] if cls == "" { cls = "ci-status-pending" } return Span(Class("ci-status-pill "+cls), g.If(title != "", Title(title)), g.Text(status)) } // CiVariable is one [variables] entry of .hearthforge-ci.toml, in file order. type CiVariable struct { Name string Default string Description string } // CiRunSummary is one row of the history list. type CiRunSummary struct { Run db.CiRun ArtifactCount int // QueuePosition is 1-based, 0 when the run does not wait in the queue. QueuePosition int } // ciQueueTitle explains the queued state in the pill tooltip. func ciQueueTitle(position int) string { switch { case position <= 0: return "Waiting in the build queue" case position == 1: return "Waiting in the build queue — next up" case position == 2: return "Waiting in the build queue — 1 run ahead" default: return "Waiting in the build queue — " + strconv.Itoa(position-1) + " runs ahead" } } // ciDuration renders how long a run or step took. It returns "" when either // timestamp is missing or unparsable. func ciDuration(start, end *string) string { if start == nil || end == nil { return "" } from, err1 := time.Parse(time.RFC3339Nano, *start) to, err2 := time.Parse(time.RFC3339Nano, *end) if err1 != nil || err2 != nil { return "" } s := int(to.Sub(from).Seconds()) if s < 0 { return "" } if s < 60 { return strconv.Itoa(s) + "s" } m, rem := s/60, s%60 if rem > 0 { return strconv.Itoa(m) + "m " + strconv.Itoa(rem) + "s" } return strconv.Itoa(m) + "m" } // ciRunNumber is the per-repo run number, falling back to the row id. func ciRunNumber(run *db.CiRun) string { if run.RepoRunID != nil { return strconv.FormatInt(*run.RepoRunID, 10) } return strconv.FormatInt(run.ID, 10) } func ciIsActive(status string) bool { return status == "pending" || status == "running" || status == "queued" } // ciDefList renders a name/description list of the help section. func ciDefList(pairs [][2]string) g.Node { return Dl(Class("ci-help-vars"), g.Map(pairs, func(p [2]string) g.Node { return g.Group{Dt(Code(g.Text(p[0]))), Dd(g.Text(p[1]))} })) } var ciPredefinedVariables = [][2]string{ {"CI", "always true"}, {"CI_PIPELINE_ID", "numeric run ID"}, {"CI_COMMIT_SHA", "full commit hash"}, {"CI_COMMIT_SHORT_SHA", "first 8 chars"}, {"CI_COMMIT_BRANCH", "branch name (empty for tags)"}, {"CI_COMMIT_TAG", "tag name (empty for branches)"}, {"CI_COMMIT_REF_NAME", "branch or tag name"}, {"CI_TRIGGER_SOURCE", "push · tag · manual"}, {"CI_REPO_NAME", "repository name"}, {"CI_SERVER_URL", "HearthForge base URL"}, } var ciStepOptions = [][2]string{ {"run_sh", "shell command to run"}, {"run_if", "skip the step unless this exits 0"}, {"always", "run even after an earlier step failed"}, {"warn_on_fail", "step warns instead of failing; the run reports warning"}, {"clear", "re-extract a clean checkout before the step"}, {"engine_socket", "mount the Docker/Podman socket for image builds; the server must set CI_ENGINE_SOCKET=1"}, {"timeout", "per-step timeout in seconds"}, } var ciArtifactTypes = [][2]string{ {"publish_file", "copy file as-is"}, {"publish_tar", ".tar archive"}, {"publish_gzip", ".tar.gz archive"}, {"publish_zstd", ".tar.zst archive"}, {"publish_zip", ".zip archive"}, } // ciHelp is the collapsible tutorial below the run list. func ciHelp(repo *db.Repo) g.Node { return Details(Class("form-card ci-help"), Summary(Class("ci-help-summary"), Span(g.Text("How to define a pipeline file")), A(Href("/assets/hearthforge-ci-template.toml"), Download(".hearthforge-ci.toml"), Class("btn btn-sm btn-secondary ci-help-download"), g.Text("Download template")), ), Div(Class("ci-help-body"), P(Class("ci-help-desc"), g.Text("Add "), Code(g.Text(".hearthforge-ci.toml")), g.Text(" to your repository root. Each "), Code(g.Text("[[steps]]")), g.Text(" entry is a step, executed in file order, and needs a "), Code(g.Text("name")), g.Text(". Other tables: "), Code(g.Text("[on]")), g.Text(" (triggers), "), Code(g.Text("[variables]")), g.Text(" (user-overridable inputs) and "), Code(g.Text("[[copy]]")), g.Text(" (files taken from another image, like "), Code(g.Text("COPY --from")), g.Text(")."), ), Div(Class("ci-help-sections"), Div(Class("ci-help-section"), H4(Class("ci-help-section-title"), g.Text("Predefined variables")), ciDefList(ciPredefinedVariables), ), Div(Class("ci-help-section"), H4(Class("ci-help-section-title"), g.Text("Status badge")), P(Class("ci-help-badge-desc"), g.Text("Embed in your README:")), Code(Class("ci-help-badge-code"), g.Text("![pipeline](/"+repo.Name+"/ci/badge.svg)")), H4(Class("ci-help-section-title"), Style("margin-top: var(--space-4)"), g.Text("Step options")), ciDefList(ciStepOptions), H4(Class("ci-help-section-title"), Style("margin-top: var(--space-4)"), g.Text("Artifact types")), ciDefList(ciArtifactTypes), ), ), ), ) } // ciRunPipeline renders the manual trigger. With no declared variables it is a // plain submit button. With variables it opens a panel of inputs seeded with // their defaults, so a field left untouched sends the default back and counts // as no override. func ciRunPipeline(repo *db.Repo, disabledReason string, variables []CiVariable) g.Node { form := Form(Method("POST"), Action("/"+repo.Name+"/ci/run"), g.If(len(variables) > 0, Div(Class("ci-run-vars"), g.Map(variables, func(v CiVariable) g.Node { return Label(Class("form-group ci-run-var"), Code(g.Text(v.Name)), g.If(v.Description != "", Span(Class("ci-run-var-desc"), g.Text(v.Description))), Input(Type("text"), Name("var_"+v.Name), Value(v.Default), AutoComplete("off")), ) }), )), Button(Type("submit"), Class("btn btn-primary btn-sm"), g.If(disabledReason != "", Disabled()), g.If(disabledReason != "", Title(disabledReason)), g.Text("Run pipeline")), ) if len(variables) == 0 { return Div(Class("inline-form"), form) } return Details(Class("confirm-details ci-run-details"), Summary(Class("btn btn-primary btn-sm"), g.If(disabledReason != "", Title(disabledReason)), g.Text("Run pipeline")), Div(Class("confirm-popup ci-run-popup"), form), ) } // CiHistory renders the Pipelines tab: the run list, the manual trigger and // the pipeline file tutorial. func CiHistory(cfg *config.Config, user *db.SessionUser, repo *db.Repo, runs []CiRunSummary, page PageInfo, manualTriggerDisabledReason string, variables []CiVariable, success, errMsg string, ) g.Node { isRunning := false for _, r := range runs { if ciIsActive(r.Run.Status) { isRunning = true break } } return Layout(Page{Title: "Pipelines — " + repo.Name, User: user, Cfg: cfg}, g.If(isRunning, Meta(g.Attr("http-equiv", "refresh"), Content("4"))), Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "ci", user), g.If(success != "", P(Class("form-success"), g.Text(success))), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Div(Class("list-header"), H2(Class("list-heading"), g.Text("Pipelines")), g.If(user != nil && user.IsAdmin, Div(Class("list-header-actions"), Form(Method("POST"), Action("/"+repo.Name+"/ci/purge-cache"), Class("inline-form"), Button(Type("submit"), Class("btn btn-secondary btn-sm"), Title("Delete all Docker cache volumes for this repository"), g.Text("Purge caches")), ), ciRunPipeline(repo, manualTriggerDisabledReason, variables), )), ), g.If(len(runs) == 0, Div(Class("empty-state"), P(g.Text("No pipeline runs yet.")), P(Class("text-muted"), g.Text("Push a "), Code(g.Text(".hearthforge-ci.toml")), g.Text(" to your repository to get started.")), )), g.If(len(runs) > 0, Ul(Class("issue-list"), g.Map(runs, func(r CiRunSummary) g.Node { return ciRunItem(repo, r) }), )), Pagination(page), ciHelp(repo), ), ) } // ciRunItem renders one entry of the run list. func ciRunItem(repo *db.Repo, r CiRunSummary) g.Node { run := r.Run title := "" if run.Status == "queued" { title = ciQueueTitle(r.QueuePosition) } shortSHA := "" if run.CommitSHA != nil && len(*run.CommitSHA) >= 8 { shortSHA = (*run.CommitSHA)[:8] } else if run.CommitSHA != nil { shortSHA = *run.CommitSHA } artifactText := strconv.Itoa(r.ArtifactCount) + " artifact" if r.ArtifactCount != 1 { artifactText += "s" } return Li(Class("issue-item"), Div(Class("release-item-header"), Div(Class("release-item-main"), A(Href("/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10)), Class("release-item-title"), ciStatusPill(run.Status, title), Span(Class("ci-run-id"), g.Text("#"+ciRunNumber(&run))), ), Div(Class("release-item-meta"), g.If(shortSHA != "", Code(Class("ci-sha"), g.Text(shortSHA))), g.If(deref(run.CommitBranch) != "", Span(Class("badge"), g.Text(deref(run.CommitBranch)))), g.If(deref(run.CommitTag) != "", Span(Class("badge"), g.Text(deref(run.CommitTag)))), Span(Class("text-muted"), g.Text(run.TriggerSource)), g.If(deref(run.TriggeredByUsername) != "", Span(Class("text-muted"), g.Text("by "+deref(run.TriggeredByUsername)))), g.If(r.ArtifactCount > 0, Span(g.Text(artifactText))), g.If(ciDuration(run.StartedAt, run.FinishedAt) != "", Span(Class("text-muted"), g.Text(ciDuration(run.StartedAt, run.FinishedAt)))), ), ), Div(Class("release-item-date"), Time(DateTime(run.CreatedAt), g.Text(util.FormatDateTime(run.CreatedAt))), ), ), ) } // ciOverrides decodes the variable overrides stored on the run. A corrupted // value renders as no overrides. func ciOverrides(raw *string) []CiVariable { if raw == nil || *raw == "" { return nil } var m map[string]string if err := json.Unmarshal([]byte(*raw), &m); err != nil { return nil } names := make([]string, 0, len(m)) for k := range m { names = append(names, k) } // The JSON object has no order, so show the names alphabetically. sort.Strings(names) out := make([]CiVariable, 0, len(names)) for _, n := range names { out = append(out, CiVariable{Name: n, Default: m[n]}) } return out } // CiRunDetail renders one pipeline run with its steps, logs and artifacts. func CiRunDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, run *db.CiRun, steps []db.CiStep, artifacts []db.CiArtifact, autoRefresh bool, queuePosition int, ) g.Node { isActive := ciIsActive(run.Status) isQueued := run.Status == "queued" queueText := ciQueueTitle(queuePosition) displayID := ciRunNumber(run) overrides := ciOverrides(run.VariableOverrides) runPath := "/" + repo.Name + "/ci/" + strconv.FormatInt(run.ID, 10) pillTitle := "" if isQueued { pillTitle = queueText } shortSHA := "" if run.CommitSHA != nil { shortSHA = *run.CommitSHA if len(shortSHA) > 8 { shortSHA = shortSHA[:8] } } triggeredBy := "triggered by " + run.TriggerSource if deref(run.TriggeredByUsername) != "" { triggeredBy += " (" + deref(run.TriggeredByUsername) + ")" } refreshHref := "?" refreshLabel := "Resume refresh" if autoRefresh { refreshHref = "?refresh=off" refreshLabel = "Pause refresh" } return Layout(Page{Title: "Pipeline #" + displayID + " — " + repo.Name, User: user, Cfg: cfg}, g.If(isActive && autoRefresh, Meta(g.Attr("http-equiv", "refresh"), Content("3"))), Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "ci", user), Div(Class("ci-run-header"), Div( H2(Class("release-detail-title"), ciStatusPill(run.Status, pillTitle), g.Text(" Pipeline #"+displayID), ), Div(Class("release-item-meta"), g.If(shortSHA != "", Code(Class("ci-sha"), g.Text(shortSHA))), g.If(deref(run.CommitBranch) != "", A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(run.CommitBranch))), Class("badge"), g.Text(deref(run.CommitBranch)))), g.If(deref(run.CommitTag) != "", A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(run.CommitTag))), Class("badge"), g.Text(deref(run.CommitTag)))), Span(Class("text-muted"), g.Text(triggeredBy)), g.If(ciDuration(run.StartedAt, run.FinishedAt) != "", Span(Class("text-muted"), g.Text(ciDuration(run.StartedAt, run.FinishedAt)))), Time(DateTime(run.CreatedAt), Class("text-muted"), g.Text(util.FormatDateTime(run.CreatedAt))), ), ), Div(Class("ci-run-actions"), g.If(isActive, A(Href(refreshHref), Class("btn btn-secondary"), g.Text(refreshLabel))), g.If(user != nil && user.IsAdmin && isActive, Form(Method("POST"), Action(runPath+"/cancel"), Class("inline-form"), Button(Type("submit"), Class("btn btn-danger"), g.Text("Cancel")), )), g.If(user != nil && user.IsAdmin && !isActive, Form(Method("POST"), Action(runPath+"/retry"), Class("inline-form"), Button(Type("submit"), Class("btn btn-secondary"), Title("Re-run with the same commit, trigger source, and variable overrides"), g.Text("Retry")), )), ), ), g.If(len(overrides) > 0, Div(Class("form-card ci-overrides"), H3(Class("section-title"), g.Text("Variable overrides")), Dl(Class("ci-vars-list"), g.Map(overrides, func(v CiVariable) g.Node { return g.Group{Dt(Code(g.Text(v.Name))), Dd(g.Text(v.Default))} })), )), Div(Class("ci-steps"), H3(Class("section-title"), g.Text("Steps")), g.If(len(steps) == 0, Div(Class("ci-step-pending"), Span(Class("text-muted"), g.Text(func() string { if isQueued { return queueText } return "Waiting to start…" }())), )), g.Map(steps, func(step db.CiStep) g.Node { return ciStepNode(step) }), ), g.If(len(artifacts) > 0, Div(Class("form-card ci-artifacts"), H3(Class("section-title"), g.Text("Artifacts")), Ul(Class("ci-artifact-list"), g.Map(artifacts, func(a db.CiArtifact) g.Node { return Li(Class("ci-artifact-item"), A(Href(runPath+"/artifacts/"+strconv.FormatInt(a.ID, 10)), Class("ci-artifact-name"), g.Text(a.Filename)), Span(Class("ci-artifact-size text-muted"), g.Text(util.FormatBytes(a.Size))), ) })), )), ), ) } // ciStepNode renders one step with its log. func ciStepNode(step db.CiStep) g.Node { dur := ciDuration(step.StartedAt, step.FinishedAt) return Details(Class("ci-step ci-step-"+step.Status), g.If(step.Status == "running", Open()), Summary(Class("ci-step-summary"), ciStatusPill(step.Status, ""), Span(Class("ci-step-name"), g.Text(step.Name)), g.If(dur != "", Span(Class("ci-step-duration text-muted"), g.Text(dur))), ), g.If(step.Log != "", Pre(Class("ci-step-log"), g.Text(step.Log))), g.If(step.Log == "" && step.Status == "running", Div(Class("ci-step-running-indicator text-muted"), g.Text("Running…"))), ) }