package dashboard import ( "errors" "html/template" "net/http" "strconv" "strings" "time" ) // Badge is the {class, label} pair rendered via the dashboard's own .pill // component (see static/dashboard.css) — Class is a modifier to combine // with "pill" in the template (e.g. `class="pill {{.Class}}"`). type Badge struct{ Class, Label string } // ipBadge picks the status pill for a queue row. fipAssociatedAt/ // settleSeconds are only consulted for state=="awaiting_self_check": if // the configured fip_settle_seconds pause (see docs/USAGE.md) hasn't // elapsed since the floating IP was associated, the row gets a distinct // "прогрев FIP" badge instead of looking identical to a row just waiting // on the agent's next poll. The time math happens here, not in the // template, same as fmtTime's existing precedent. func ipBadge(state, result string, fipAssociatedAt *time.Time, settleSeconds int) Badge { switch state { case "done", "failed": switch result { case "pass": return Badge{"pill-success", "pass"} case "partial": return Badge{"pill-warning", "partial"} case "cancelled": return Badge{"pill-cancel", "cancelled"} default: return Badge{"pill-danger", "fail"} } case "queued": return Badge{"pill-neutral", "queued"} case "awaiting_self_check": if settleSeconds > 0 && fipAssociatedAt != nil && time.Now().Before(fipAssociatedAt.Add(time.Duration(settleSeconds)*time.Second)) { return Badge{"pill-warning", "прогрев FIP"} } return Badge{"pill-info", state} default: return Badge{"pill-info", state} } } func validatorBadge(state string) Badge { switch state { case "idle": return Badge{"pill-success", "idle"} case "unreachable": return Badge{"pill-danger", "unreachable"} case "unregistered": return Badge{"pill-neutral", "unregistered"} default: return Badge{"pill-info", 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 } // joinInts renders a []int as a comma-separated string, for pre-filling the // inbound-checks ports form field on /settings. func joinInts(ints []int, sep string) string { parts := make([]string, len(ints)) for i, n := range ints { parts[i] = strconv.Itoa(n) } return strings.Join(parts, sep) } var funcMap = template.FuncMap{ "ipBadge": ipBadge, "validatorBadge": validatorBadge, // proberBadge reuses validatorBadge as-is: prober states // (unregistered/idle/unreachable) are a subset of validator states // with identical labels/styles, so no separate function is needed. "proberBadge": validatorBadge, "fmtTime": fmtTime, "deref": derefStr, "join": strings.Join, "joinInts": joinInts, } 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) } }