package web import ( "embed" "errors" "fmt" "html/template" "io" "log" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "bugabinga.net/luci/internal/artifact" "bugabinga.net/luci/internal/ciconfig" "bugabinga.net/luci/internal/gitrepo" "bugabinga.net/luci/internal/history" "bugabinga.net/luci/internal/inbox" "bugabinga.net/luci/internal/logs" "bugabinga.net/luci/internal/proquint" "bugabinga.net/luci/internal/runstate" ) type Server struct { DataDir, InboxDir, ThemeCSS string RepoRoots []string } type executionConfig struct { SourceFile, Source, Image, Matrix, Trigger string Commands []string Values map[string]string } type configFile struct { Name, Body string } type configSnapshot struct { Files []configFile Jobs map[string]executionConfig Error string } type childPage struct { runView Log, LogError, RawURL string LogHTML template.HTML Truncated bool Steps []Step Config *executionConfig } type outcomeView struct { Status string Count int } type runPage struct { Known bool Running bool Parent runView Children []childPage Failed []childPage Passed []childPage Other []childPage Outcomes []outcomeView HasOutput bool Output childPage Config configSnapshot Warnings []string Artifacts []artifact.File } func Handler(dataDir, inboxDir, themeCSS string, repoRoots []string) http.Handler { s := Server{DataDir: dataDir, InboxDir: inboxDir, ThemeCSS: themeCSS, RepoRoots: repoRoots} mux := http.NewServeMux() mux.HandleFunc("/", s.dashboard) mux.HandleFunc("/repos/", s.repo) mux.HandleFunc("/runs/", s.run) mux.HandleFunc("/docs", s.docs) mux.HandleFunc("/llms.txt", s.llms) mux.HandleFunc("/theme.css", s.theme) return mux } func (s Server) state() ([]history.Event, []inbox.Pending, []runstate.Active, []string, error) { return LoadState(s.DataDir, s.InboxDir) } func LoadState(dataDir, inboxDir string) ([]history.Event, []inbox.Pending, []runstate.Active, []string, error) { events, err := history.ReadAll(dataDir) if err != nil { return nil, nil, nil, nil, err } pending, inboxWarnings, err := inbox.List(inboxDir) if err != nil { return nil, nil, nil, nil, err } active, activeWarnings, err := (runstate.Store{DataDir: dataDir}).List() if err != nil { return nil, nil, nil, nil, err } warnings := make([]string, 0, len(inboxWarnings)+len(activeWarnings)) for _, warning := range append(inboxWarnings, activeWarnings...) { log.Printf("luci web state warning: %v", warning) warnings = append(warnings, warning.Error()) } return events, pending, active, warnings, nil } func (s Server) dashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } events, pending, active, warnings, err := s.state() if err != nil { internalError(w, "load state", err) return } view := buildDashboard(time.Now(), events, pending, active, r.URL.Query().Get("all") == "1") view.Warnings = warnings s.render(w, "Dashboard", "dashboard", view) } func (s Server) repo(w http.ResponseWriter, r *http.Request) { repo := strings.TrimPrefix(r.URL.Path, "/repos/") events, pending, active, warnings, err := s.state() if err != nil { internalError(w, "load state", err) return } var filtered []history.Event for _, e := range events { if e.Repo == repo { filtered = append(filtered, e) } } var queued []inbox.Pending for _, p := range pending { rp, _, _, _, _ := triggerView(p.Event) if rp == repo { queued = append(queued, p) } } var running []runstate.Active for _, a := range active { if a.Repo == repo { running = append(running, a) } } view := buildDashboard(time.Now(), filtered, queued, running, true) rows := append(append(view.Attention, view.Progress...), view.Success...) s.render(w, "Repo "+repo, "repo", map[string]any{"Repo": repo, "Rows": rows, "Warnings": warnings}) } func (s Server) run(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/runs/") parts := strings.Split(rest, "/") if len(parts) == 3 && parts[1] == "logs" { if !cleanSegment(parts[2]) { http.NotFound(w, r) return } } else if len(parts) >= 2 && parts[1] == "artifacts" { if len(parts) > 2 && !cleanArtifactParts(parts[2:]) { http.NotFound(w, r) return } } else if len(parts) != 1 || parts[0] == "" { http.NotFound(w, r) return } events, pending, active, warnings, err := s.state() if err != nil { internalError(w, "load state", err) return } runID, ok := resolveProquintRun(parts[0], events, pending, active) if !ok { http.NotFound(w, r) return } if len(parts) == 3 && parts[1] == "logs" { s.rawLog(w, runID, parts[2], events) return } if len(parts) >= 2 && parts[1] == "artifacts" { s.artifacts(w, runID, parts[2:]) return } detail := buildRun(runID, time.Now(), events, pending, active) page := runPage{Known: detail.Known, Parent: detail.Parent, Warnings: warnings} legacyCounts := map[string]int{} for _, child := range detail.Children { if strings.HasPrefix(child.ChildID, "legacy-") { legacyCounts[legacyLogName(runID, child.Job)]++ } } for _, child := range detail.Children { name := runID + "-" + child.ChildID if strings.HasPrefix(child.ChildID, "legacy-") { name = legacyLogName(runID, child.Job) if legacyCounts[name] > 1 { page.Children = append(page.Children, childPage{runView: child, LogError: "Ambiguous legacy log identity."}) continue } } page.Children = append(page.Children, s.child(runID, child, name)) } page.Running = detail.Parent.Status == "running" || detail.Parent.Status == "preparing" || detail.Parent.Status == "queued" for _, child := range detail.Children { if child.Status == "running" || child.Status == "preparing" || child.Status == "queued" { page.Running = true } } page.Config, err = s.configAt(detail) if err != nil { internalError(w, "read configuration", err) return } for i := range page.Children { if config, ok := page.Config.Jobs[page.Children[i].Job]; ok { config := config values := config.Values if len(page.Children[i].MatrixValues) > 0 { values = page.Children[i].MatrixValues } config.Matrix = matrixDisplay(values) if config.Matrix == "" { config.Matrix = "none" } config.Image = ciconfig.Interpolate(config.Image, values) config.Commands = interpolateCommands(config.Commands, values) page.Children[i].Config = &config } } for _, child := range page.Children { switch child.Status { case "failed": page.Failed = append(page.Failed, child) case "success": page.Passed = append(page.Passed, child) default: page.Other = append(page.Other, child) } if !page.HasOutput && detail.Parent.Status == "success" && child.Status == "success" && child.Log != "" { page.HasOutput, page.Output = true, child } } page.Outcomes = childOutcomes(page.Children) page.Artifacts, err = (artifact.Store{DataDir: s.DataDir}).List(runID, "") if err != nil { internalError(w, "list artifacts", err) return } s.render(w, "Run "+runID, "run", page) } // resolveProquintRun resolves complete and block-prefix proquints only when // exactly one known numeric run ID matches. Other route tokens stay literal. func resolveProquintRun(token string, events []history.Event, pending []inbox.Pending, active []runstate.Active) (string, bool) { if _, ok := proquint.PrefixBytes(token); !ok { return token, true } ids := map[string]bool{} for _, event := range events { ids[event.RunID] = true } for _, current := range pending { ids[current.ID] = true } for _, current := range active { ids[current.RunID] = true } match := "" for id := range ids { value, err := strconv.ParseInt(id, 10, 64) if err != nil || !strings.HasPrefix(proquint.Encode(value), token) { continue } if match != "" { return "", false } match = id } return match, match != "" } // configAt reads direct .ci KDL files at the run revision. // Raw files remain available when parsing fails, so invalid historic config is visible. func (s Server) configAt(detail runDetailView) (configSnapshot, error) { if len(s.RepoRoots) == 0 || !detail.Known || detail.Parent.Repo == "" || detail.Parent.FullRev == "—" { return configSnapshot{}, nil } repoPath, err := gitrepo.RepoPath(s.RepoRoots, detail.Parent.Repo) if err != nil { return configSnapshot{}, err } files, err := gitrepo.FilesAtRev(repoPath, detail.Parent.FullRev, ".ci") if err != nil { return configSnapshot{}, err } direct := map[string]string{} for path, content := range files { if !strings.Contains(path, "/") && strings.HasSuffix(path, ".kdl") { direct[path] = content } } page := configSnapshot{Jobs: map[string]executionConfig{}} for _, name := range sortedConfigFiles(direct) { page.Files = append(page.Files, configFile{Name: name, Body: direct[name]}) } cfg, err := ciconfig.ParseFiles(direct) if err != nil { page.Error = err.Error() return page, nil } for _, job := range cfg.Jobs { for _, expanded := range job.ExpandMatrix() { page.Jobs[expanded.Name] = executionConfig{ SourceFile: job.SourceFile, Source: job.Source, Image: job.Image, Trigger: triggerSummary(job), Commands: append([]string(nil), job.Run...), Values: expanded.Values, } } } return page, nil } func sortedConfigFiles(files map[string]string) []string { names := make([]string, 0, len(files)) for name := range files { names = append(names, name) } sort.Strings(names) return names } func interpolateCommands(commands []string, values map[string]string) []string { out := make([]string, len(commands)) for i, command := range commands { out[i] = ciconfig.Interpolate(command, values) } return out } func childOutcomes(children []childPage) []outcomeView { counts := map[string]int{} for _, child := range children { counts[child.Status]++ } order := []string{"failed", "success", "running", "preparing", "queued", "skipped"} outcomes := make([]outcomeView, 0, len(counts)) for _, status := range order { if counts[status] > 0 { outcomes = append(outcomes, outcomeView{Status: status, Count: counts[status]}) delete(counts, status) } } unknown := make([]string, 0, len(counts)) for status := range counts { unknown = append(unknown, status) } sort.Strings(unknown) for _, status := range unknown { outcomes = append(outcomes, outcomeView{Status: status, Count: counts[status]}) } return outcomes } func triggerSummary(job ciconfig.Job) string { var triggers []string if job.Trigger.Push != nil { triggers = append(triggers, "push") } if job.Trigger.Schedule != nil { triggers = append(triggers, "schedule "+job.Trigger.Schedule.Expression) } if len(triggers) == 0 { return "manual-only" } return strings.Join(triggers, " · ") } func (s Server) child(runID string, view runView, name string) childPage { page := childPage{runView: view, RawURL: "/runs/" + runID + "/logs/" + view.ChildID} tail, err := (logs.Store{DataDir: s.DataDir}).Tail(name, 256<<10) switch { case err == nil: page.Log, page.Truncated, page.LogHTML = tail.Text, tail.Truncated, HighlightLog(tail.Text) case errors.Is(err, os.ErrNotExist): page.LogError = "No log available." default: log.Printf("luci web read log %q: %v", name, err) page.LogError = "Log unavailable." } if file, err := (logs.Store{DataDir: s.DataDir}).Spool(name); err == nil { if raw, err := io.ReadAll(file); err == nil { page.Steps = ParseSteps(string(raw)) } file.Close() } else if !errors.Is(err, os.ErrNotExist) { log.Printf("luci web read steps %q: %v", name, err) } return page } func (s Server) rawLog(w http.ResponseWriter, runID, childID string, events []history.Event) { if !cleanSegment(runID) || !cleanSegment(childID) { http.NotFound(w, nil) return } name := runID + "-" + childID if strings.HasPrefix(childID, "legacy-") { detail := buildRun(runID, time.Now(), events, nil, nil) found := false for _, child := range detail.Children { if child.ChildID == childID { name = legacyLogName(runID, child.Job) found = true break } } matches := 0 for _, child := range detail.Children { if strings.HasPrefix(child.ChildID, "legacy-") && legacyLogName(runID, child.Job) == name { matches++ } } if !found || matches != 1 { http.NotFound(w, nil) return } } file, err := (logs.Store{DataDir: s.DataDir}).Spool(name) if errors.Is(err, os.ErrNotExist) { http.Error(w, "log not found", http.StatusNotFound) return } if err != nil { internalError(w, "read raw log", err) return } defer file.Close() w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.Copy(w, file) } func (s Server) artifacts(w http.ResponseWriter, runID string, parts []string) { store := artifact.Store{DataDir: s.DataDir} if len(parts) == 0 { files, err := store.List(runID, "") if err != nil { internalError(w, "list artifacts", err) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") for _, file := range files { _, _ = fmt.Fprintf(w, "%s\\t%d\\n", file.Name, file.Size) } return } file, err := store.Open(runID, strings.Join(parts, "/")) if errors.Is(err, os.ErrNotExist) { http.NotFound(w, nil) return } if err != nil { internalError(w, "read artifact", err) return } defer file.Close() w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Security-Policy", "sandbox") w.Header().Set("Content-Disposition", "attachment") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Content-Type", "application/octet-stream") _, _ = io.Copy(w, file) } func cleanArtifactParts(parts []string) bool { for _, part := range parts { if !cleanSegment(part) { return false } } return true } func internalError(w http.ResponseWriter, operation string, err error) { log.Printf("luci web %s: %v", operation, err) http.Error(w, "internal server error", http.StatusInternalServerError) } func cleanSegment(v string) bool { return v != "" && filepath.Base(v) == v && v != "." && v != ".." } func legacyLogName(runID, job string) string { return runID + "-" + legacySafeName(job) } // LegacyLogName exposes the pre-positional child log naming for the CLI. func LegacyLogName(runID, job string) string { return legacyLogName(runID, job) } func legacySafeName(value string) string { value = strings.ReplaceAll(value, string(filepath.Separator), "_") value = strings.NewReplacer("[", "_", "]", "_", ",", "_", "=", "-").Replace(value) return value } func (s Server) docs(w http.ResponseWriter, r *http.Request) { s.render(w, "Docs", "docs", map[string]bool{}) } func (s Server) llms(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, llmsText) } func (s Server) themeAvailable() bool { if s.ThemeCSS == "" { return false } info, err := os.Stat(s.ThemeCSS) return err == nil && !info.IsDir() } func (s Server) theme(w http.ResponseWriter, r *http.Request) { if !s.themeAvailable() { http.NotFound(w, r) return } data, err := os.ReadFile(s.ThemeCSS) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") _, _ = w.Write(data) } func (s Server) render(w http.ResponseWriter, title, page string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") tpl, ok := pageTemplates[page] if !ok { internalError(w, "render page", errors.New("unknown page "+page)) return } _ = tpl.Execute(w, map[string]any{"Title": title, "CSS": template.CSS(pageCSS), "Theme": s.themeAvailable(), "Data": data}) } //go:embed assets var assets embed.FS var baseTemplate = template.Must(template.New("page").Funcs(template.FuncMap{ "upper": strings.ToUpper, "commandText": commandText, "commandHTML": highlightCommand, "kdl": HighlightKDL, "runToken": runToken, "runDisplay": runDisplay, }).Parse(mustAsset("assets/layout.tmpl"))) var pageTemplates = map[string]*template.Template{ "dashboard": page("dashboard"), "repo": page("repo"), "run": page("run"), "docs": page("docs"), } func page(name string) *template.Template { return template.Must(template.Must(baseTemplate.Clone()).Parse(mustAsset("assets/" + name + ".tmpl"))) } var pageCSS = mustAsset("assets/style.css") var llmsText = mustAsset("assets/llms.txt") func mustAsset(name string) string { data, err := assets.ReadFile(name) if err != nil { panic(err) } return string(data) }