Files
cloud-ip-validator/internal/dashboard/render.go
T

155 lines
4.9 KiB
Go

package dashboard
import (
"errors"
"html/template"
"net/http"
"strings"
"time"
)
// Badge is the {class, label} pair rendered as a colored pill — see
// static/dashboard.css for the .badge-* classes.
type Badge struct{ Class, Label string }
func ipBadge(state, result string) Badge {
switch state {
case "done", "failed":
switch result {
case "pass":
return Badge{"badge badge-pass", "pass"}
case "partial":
return Badge{"badge badge-partial", "partial"}
case "cancelled":
return Badge{"badge badge-cancelled", "cancelled"}
default:
return Badge{"badge badge-fail", "fail"}
}
case "queued":
return Badge{"badge badge-queued", "queued"}
default:
return Badge{"badge badge-inprogress", state}
}
}
func validatorBadge(state string) Badge {
switch state {
case "idle":
return Badge{"badge badge-idle", "idle"}
case "unreachable":
return Badge{"badge badge-unreachable", "unreachable"}
case "unregistered":
return Badge{"badge badge-unregistered", "unregistered"}
default:
return Badge{"badge badge-inprogress", state} // assigned / checking
}
}
// fmtTime accepts time.Time, *time.Time, or nil and renders a local
// timestamp or an em-dash placeholder — covers both the value-typed
// (CreatedAt, CheckedAt, ...) and pointer-typed (AssignedAt,
// AggregatedAt, ...) timestamp fields in the DTOs without two template
// funcs.
func fmtTime(v interface{}) string {
switch t := v.(type) {
case time.Time:
if t.IsZero() {
return "—"
}
return t.Local().Format("2006-01-02 15:04:05")
case *time.Time:
if t == nil {
return "—"
}
return t.Local().Format("2006-01-02 15:04:05")
default:
return "—"
}
}
func derefStr(s *string) string {
if s == nil || *s == "" {
return "—"
}
return *s
}
var funcMap = template.FuncMap{
"ipBadge": ipBadge,
"validatorBadge": validatorBadge,
"fmtTime": fmtTime,
"deref": derefStr,
"join": strings.Join,
}
func parseTemplates() (*template.Template, error) {
return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
}
// bannerData drives the shared error-banner partial (see templates/
// layout.html's "banner_inner" block and templates/error_banner.html).
// Client distinguishes a business-logic 4xx (rendered in yellow) from a
// server-side 5xx or transport failure such as control-api being
// unreachable (rendered in red).
type bannerData struct {
Message string
Client bool
}
// PageData is embedded (anonymously) by every full-page template's data
// struct so {{.Banner}}/{{.ActiveNav}} resolve via Go's promoted-field
// rule in both the page template and the shared partials (page_header,
// banner_inner) it includes. ActiveNav drives the nav bar's aria-current.
type PageData struct {
Banner bannerData
ActiveNav string
}
func bannerFor(err error) bannerData {
if err == nil {
return bannerData{}
}
var ae *apiErr
if errors.As(err, &ae) {
return bannerData{Message: ae.Error(), Client: ae.Status >= 400 && ae.Status < 500}
}
return bannerData{Message: err.Error(), Client: false}
}
// renderPage renders a full page (extends "layout") for a plain GET
// navigation. actionErr (if any — e.g. the primary control-api call for
// this page failed) is surfaced via the embedded PageData.Banner, which
// the caller must have already set via bannerFor.
func (s *Server) renderPage(w http.ResponseWriter, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.Log.Error("render page", "template", name, "err", err)
}
}
// renderFragment renders an htmx swap target (name) plus, appended to the
// same response body, an out-of-band update of the shared #error-banner
// (see templates/error_banner.html) reflecting actionErr — nil clears any
// previously shown banner, matching htmx's id-based morph.
//
// The response status is always 200, deliberately: htmx's default
// response-handling config only processes swaps (including OOB swaps) for
// 2xx responses, and this dashboard needs the banner to render on every
// response regardless of whether the underlying control-api call
// succeeded. Because every mutating handler re-fetches the authoritative
// list from control-api after attempting its mutation (success or not)
// before calling renderFragment, the primary content is always a true
// reflection of current state — success/failure is communicated by the
// banner text and by whether the content actually changed, not by HTTP
// status.
func (s *Server) renderFragment(w http.ResponseWriter, name string, data interface{}, actionErr error) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.Log.Error("render fragment", "template", name, "err", err)
return
}
if err := s.tmpl.ExecuteTemplate(w, "error_banner", bannerFor(actionErr)); err != nil {
s.Log.Error("render error banner", "err", err)
}
}