Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/web/web.go

Raw
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)
}