repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
services/luci/internal/web/web.go
Rawpackage 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)
}