2026-08-23 20:39:22 +03:00
|
|
|
package dashboard
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"html/template"
|
|
|
|
|
"net/http"
|
2026-08-26 19:45:48 +03:00
|
|
|
"strconv"
|
2026-08-23 20:39:22 +03:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-23 21:58:19 +03:00
|
|
|
// 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}}"`).
|
2026-08-23 20:39:22 +03:00
|
|
|
type Badge struct{ Class, Label string }
|
|
|
|
|
|
2026-08-24 10:29:08 +03:00
|
|
|
// 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
|
2026-09-13 23:54:35 +03:00
|
|
|
// 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.
|
2026-08-24 10:29:08 +03:00
|
|
|
func ipBadge(state, result string, fipAssociatedAt *time.Time, settleSeconds int) Badge {
|
2026-08-23 20:39:22 +03:00
|
|
|
switch state {
|
|
|
|
|
case "done", "failed":
|
|
|
|
|
switch result {
|
|
|
|
|
case "pass":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-success", "pass"}
|
2026-08-23 20:39:22 +03:00
|
|
|
case "partial":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-warning", "partial"}
|
2026-08-23 20:39:22 +03:00
|
|
|
case "cancelled":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-cancel", "cancelled"}
|
2026-08-23 20:39:22 +03:00
|
|
|
default:
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-danger", "fail"}
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
case "queued":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-neutral", "queued"}
|
2026-09-13 23:54:35 +03:00
|
|
|
case "occupied":
|
|
|
|
|
return Badge{"pill-occupied", "занят"}
|
2026-08-24 10:29:08 +03:00
|
|
|
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}
|
2026-08-23 20:39:22 +03:00
|
|
|
default:
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-info", state}
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validatorBadge(state string) Badge {
|
|
|
|
|
switch state {
|
|
|
|
|
case "idle":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-success", "idle"}
|
2026-08-23 20:39:22 +03:00
|
|
|
case "unreachable":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-danger", "unreachable"}
|
2026-08-23 20:39:22 +03:00
|
|
|
case "unregistered":
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-neutral", "unregistered"}
|
2026-08-23 20:39:22 +03:00
|
|
|
default:
|
2026-08-23 21:58:19 +03:00
|
|
|
return Badge{"pill-info", state} // assigned / checking
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 19:45:48 +03:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 20:39:22 +03:00
|
|
|
var funcMap = template.FuncMap{
|
|
|
|
|
"ipBadge": ipBadge,
|
|
|
|
|
"validatorBadge": validatorBadge,
|
2026-08-26 20:47:54 +03:00
|
|
|
// 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,
|
2026-09-18 10:52:27 +03:00
|
|
|
"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 "адресов"
|
|
|
|
|
}
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-01 11:35:24 +03:00
|
|
|
// AuthEnabled/User are filled in by renderPage (see withAuthInfo) so the
|
|
|
|
|
// sidebar can show the signed-in user and the logout button.
|
|
|
|
|
AuthEnabled bool
|
|
|
|
|
User string
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2026-10-01 11:35:24 +03:00
|
|
|
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data interface{}) {
|
2026-08-23 20:39:22 +03:00
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
2026-10-01 11:35:24 +03:00
|
|
|
data = s.withAuthInfo(r, data)
|
2026-08-23 20:39:22 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|