package api import ( "embed" "fmt" "html/template" "net/http" "sort" "strings" "time" "bugabinga.net/toad/internal/enroll" "bugabinga.net/toad/internal/rollout" "bugabinga.net/toad/internal/store" ) //go:embed web/* var webAssets embed.FS var dashboardTemplate = template.Must(template.New("dashboard.html").Funcs(template.FuncMap{ "shortDigest": shortDigest, "shorten": shorten, "elapsed": elapsed, "probation": probation, }).ParseFS(webAssets, "web/dashboard.html")) type dashboard struct { Services []serviceView Selected *serviceView Healthy int } type serviceView struct { Service enroll.Service State store.State Container rollout.ContainerState Unit rollout.UnitState Health string Operations []operationView } type operationView struct { Operation store.Operation Log string } func (m *Manager) serveDashboard(w http.ResponseWriter, r *http.Request) { secureWebResponse(w) views := make([]serviceView, 0, len(m.Enrollment.Names())) for _, name := range m.Enrollment.Names() { service, _ := m.Enrollment.Lookup(name) view := serviceView{Service: service, Health: "unknown"} view.State, _ = m.Store.ReadState(name) view.Container, _ = m.Engine.Host.Container(r.Context(), service.ContainerName) view.Unit, _ = m.Engine.Host.Unit(r.Context(), service.UnitName()) if view.Container.Running && view.Unit.ActiveState == "active" && m.Engine.Host.Probe(r.Context(), service.ReadinessURL) == nil { view.Health = "healthy" } else if view.State.Current != nil { view.Health = "unhealthy" } views = append(views, view) } sort.Slice(views, func(i, j int) bool { return views[i].Service.Name < views[j].Service.Name }) selected := r.URL.Query().Get("service") if selected == "" && len(views) > 0 { selected = views[0].Service.Name } healthy := 0 for _, view := range views { if view.Health == "healthy" { healthy++ } } var detail *serviceView for index := range views { if views[index].Service.Name == selected { detail = &views[index] break } } if selected != "" && detail == nil { http.NotFound(w, r) return } if detail != nil { detail.Operations = m.operationViews(detail.Service.Name) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := dashboardTemplate.Execute(w, dashboard{Services: views, Selected: detail, Healthy: healthy}); err != nil { http.Error(w, "render dashboard", http.StatusInternalServerError) } } func (m *Manager) operationViews(service string) []operationView { ids, err := m.Store.OperationIDs(service) if err != nil { return nil } sort.Sort(sort.Reverse(sort.StringSlice(ids))) if len(ids) > 20 { ids = ids[:20] } views := make([]operationView, 0, len(ids)) for _, id := range ids { operation, err := m.Store.ReadOperation(service, id) if err != nil { continue } log, _ := m.Store.ReadLog(service, id) views = append(views, operationView{Operation: operation, Log: string(log)}) } return views } func serveWebAsset(w http.ResponseWriter, r *http.Request) { secureWebResponse(w) name := strings.TrimPrefix(r.URL.Path, "/assets/") if name != "dashboard.css" && name != "dashboard.js" && name != "symbols-nerd-font-mono.woff2" { http.NotFound(w, r) return } data, err := webAssets.ReadFile("web/" + name) if err != nil { http.NotFound(w, r) return } contentType := map[string]string{ "dashboard.css": "text/css; charset=utf-8", "dashboard.js": "text/javascript; charset=utf-8", "symbols-nerd-font-mono.woff2": "font/woff2", }[name] w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write(data) } func secureWebResponse(w http.ResponseWriter) { w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; font-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") } func shortDigest(revision *store.Revision) string { if revision == nil { return "none" } return shorten(revision.Digest) } func shorten(value string) string { if len(value) <= 24 { return value } return value[:15] + "…" + value[len(value)-7:] } func elapsed(value time.Time) string { if value.IsZero() { return "never" } duration := time.Since(value) if duration < time.Minute { return "now" } if duration < time.Hour { return fmt.Sprintf("%dm", int(duration.Minutes())) } if duration < 24*time.Hour { return fmt.Sprintf("%dh", int(duration.Hours())) } return fmt.Sprintf("%dd", int(duration.Hours()/24)) } func probation(service enroll.Service) string { return fmt.Sprintf("deadline %s · stable %s · restarts %d", service.Probation.Deadline, service.Probation.StableFor, service.Probation.RestartBudget) }