gitmote / internal / webui / ci.go · history · branches
6479 bytes · raw
package webui
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/render"
"github.com/atmin/gitmote/internal/store"
)
// ciRunsLimit caps the recent-runs page; a 101st run becomes the "more" marker
// rather than being silently dropped.
const ciRunsLimit = 100
// ciRuns renders a repo's recent CI runs, newest first.
func (h *Handler) ciRuns(w http.ResponseWriter, r *http.Request, repoName string) {
rp, ok := h.ciRepo(w, r, repoName)
if !ok {
return
}
runs, err := h.md.ListRuns(r.Context(), rp.ID, ciRunsLimit+1)
if err != nil {
h.serverError(w, "list runs", err)
return
}
more := false
if len(runs) > ciRunsLimit {
runs, more = runs[:ciRunsLimit], true
}
h.render(w, "ci_runs.html", ciRunsData{
base: h.base(r, "", ""),
Repo: rp.Name,
Runs: runs,
More: more,
})
}
// ciRun renders either a run's detail (arg = "{id}") or one job's log
// (arg = "{id}/job/{jid}/log").
func (h *Handler) ciRun(w http.ResponseWriter, r *http.Request, repoName, arg string) {
idStr, rest, hasRest := strings.Cut(arg, "/")
runID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
rp, ok := h.ciRepo(w, r, repoName)
if !ok {
return
}
run, err := h.md.GetRun(r.Context(), runID)
if errors.Is(err, meta.ErrNotFound) || (err == nil && run.RepoID != rp.ID) {
http.NotFound(w, r) // unknown run, or a run belonging to another repo
return
}
if err != nil {
h.serverError(w, "get run", err)
return
}
if !hasRest {
h.ciRunDetail(w, r, rp, run)
return
}
// Sub-resources of a run: "job/{jid}/log" (the log page) and
// "job/{jid}/log/live" (the live-tail JSON the page polls while it runs).
parts := strings.Split(rest, "/")
live := len(parts) == 4 && parts[3] == "live"
if (len(parts) != 3 && !live) || parts[0] != "job" || parts[2] != "log" {
http.NotFound(w, r)
return
}
jobID, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if live {
h.ciJobLogLive(w, r, run, jobID)
return
}
h.ciJobLog(w, r, rp, run, jobID)
}
func (h *Handler) ciRunDetail(w http.ResponseWriter, r *http.Request, rp *meta.Repo, run *meta.Run) {
jobs, err := h.md.ListJobs(r.Context(), run.ID)
if err != nil {
h.serverError(w, "list jobs", err)
return
}
h.render(w, "ci_run.html", ciRunData{
base: h.base(r, "", ""),
Repo: rp.Name,
Run: *run,
Jobs: jobs,
})
}
func (h *Handler) ciJobLog(w http.ResponseWriter, r *http.Request, rp *meta.Repo, run *meta.Run, jobID int64) {
job, err := h.md.GetJob(r.Context(), jobID)
if errors.Is(err, meta.ErrNotFound) || (err == nil && job.RunID != run.ID) {
http.NotFound(w, r) // unknown job, or a job under a different run
return
}
if err != nil {
h.serverError(w, "get job", err)
return
}
data := ciLogData{
base: h.base(r, "", ""),
Repo: rp.Name,
Run: *run,
Job: *job,
}
switch {
case !isTerminalStatus(job.Status):
// Still queued/running: the page tails the live buffer and reloads on done.
data.Live = true
case job.LogKey == "":
data.Note = "No log yet — the job hasn't reported."
default:
h.loadLog(r, job.LogKey, &data)
}
h.render(w, "ci_log.html", data)
}
// ciJobLogLive answers the log page's poll for a running job: the live buffer's
// bytes from offset onward, the next offset, and whether the job has finished (so
// the page reloads to the durable log). Falls back to done-by-status when there is
// no live buffer (never started, swept, or the live store is disabled).
func (h *Handler) ciJobLogLive(w http.ResponseWriter, r *http.Request, run *meta.Run, jobID int64) {
job, err := h.md.GetJob(r.Context(), jobID)
if errors.Is(err, meta.ErrNotFound) || (err == nil && job.RunID != run.ID) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get job", err)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
resp := liveLogResp{Next: offset}
if data, next, done, ok := h.readLive(jobID, offset); ok {
resp.Bytes, resp.Next, resp.Done = string(data), next, done
} else {
resp.Done = isTerminalStatus(job.Status)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// readLive reads the live buffer, returning ok=false when the store is disabled.
func (h *Handler) readLive(jobID int64, offset int) (data []byte, next int, done, ok bool) {
if h.live == nil {
return nil, offset, false, false
}
return h.live.Read(jobID, offset)
}
// liveLogResp is the JSON the log page polls: new bytes, the next offset to poll
// from, and whether the job has finished.
type liveLogResp struct {
Bytes string `json:"bytes"`
Next int `json:"next"`
Done bool `json:"done"`
}
// isTerminalStatus reports whether a run/job status is a finished state.
func isTerminalStatus(s meta.RunStatus) bool {
switch s {
case meta.RunPassed, meta.RunFailed, meta.RunError, meta.RunSuperseded:
return true
default:
return false
}
}
// loadLog fetches a job's log blob and fills data with either the ANSI-rendered
// HTML or, over the render cap, a plain-text prefix with a note. Read failures
// degrade to a note — the run detail already showed the status.
func (h *Handler) loadLog(r *http.Request, key string, data *ciLogData) {
rc, err := h.store.Get(r.Context(), key)
if errors.Is(err, store.ErrNotFound) {
data.Note = "Log not found in the object store."
return
}
if err != nil {
h.log.Error("get ci log", "key", key, "error", err)
data.Note = "Could not read the log."
return
}
defer func() { _ = rc.Close() }()
raw, err := io.ReadAll(io.LimitReader(rc, render.MaxSize+1))
if err != nil {
h.log.Error("read ci log", "key", key, "error", err)
data.Note = "Could not read the log."
return
}
if len(raw) > render.MaxSize {
data.Note = "Log is large; showing the first part as plain text."
data.Plain = string(raw[:render.MaxSize])
return
}
data.Log = render.ANSI(raw)
}
// ciRepo resolves the repo record for a CI page. CI reads meta + the object
// store, not git, so it needs no materialization.
func (h *Handler) ciRepo(w http.ResponseWriter, r *http.Request, repoName string) (*meta.Repo, bool) {
rp, err := h.md.GetRepo(r.Context(), repoName)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return nil, false
}
if err != nil {
h.serverError(w, "get repo", err)
return nil, false
}
return rp, true
}