Files
cloud-ip-validator/internal/dashboard/render.go
T
ayurishchevandClaude Sonnet 5 792b7fbb84 Show check-type usage and stats on the target groups dashboard page
Each target group's relationship to the check types that reference it was
previously invisible without cross-referencing /check-types by hand.
handleTargetsPage/renderTargetsTable now resolve that server-side via
loadTargetsPage (group -> referencing check types, enabled or not) and
render it as a status pill per group, plus a stats row (group count,
total targets, check types using them, unused groups) kept live via an
out-of-band swap on every create/update/delete.

Also auto-sizes the targets textarea as you type (targets.html), and
lets the stat-card grid collapse to however many state cards actually
exist instead of leaving empty background where a fixed repeat(6, ...)
had no card to fill.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:52:27 +03:00

207 lines
7.0 KiB
Go

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. state=="occupied" gets
// its own pill, deliberately separate from the done/failed result switch
// below — it means the check cycle never ran (the floating IP was already
// bound to another port at claim time), not that a check failed.
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 "occupied":
return Badge{"pill-occupied", "занят"}
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,
"pluralAddr": pluralAddr,
}
// pluralAddr returns the correctly declined Russian word for "address"
// (адрес/адреса/адресов) for the given count — standard count%10/count%100
// rule, with the usual 11-14 exception.
func pluralAddr(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "адресов"
}
switch n % 10 {
case 1:
return "адрес"
case 2, 3, 4:
return "адреса"
default:
return "адресов"
}
}
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)
}
}