The cloud is live: the address list submitted as "free" (bootstrap config
or POST /api/v1/admin/ips) can drift by the time the orchestrator claims
it, or an operator can queue an already-occupied address by mistake.
Neutron's floating-IP association is a blind "last write wins" PUT with
no conflict error to catch, so associateFIP now checks the FIP's PortID
(already fetched via GetFloatingIPByAddress) before associating, guarded
against the false-positive of the FIP already belonging to this same
validator's own port.
A match routes the address straight to a new terminal ip_queue.state
("occupied", distinct from failed/fail) via db.MarkFIPOccupied — no
retries, since Neutron won't free it on its own and requeuing would let
it be reclaimed again next tick, starving the rest of the queue — plus a
dedicated fip_occupied audit event. Resubmitting the address later (once
the conflict is resolved) resets it to queued via the existing
POST /api/v1/admin/ips resubmit path (CancelIP/ListExpiredLeases updated
to treat occupied as terminal too). admin-dashboard gets its own "занят"
badge, distinct from fail/partial/cancelled.
Rebuilt bin/{control-api,admin-dashboard,prober,validator-agent} and
bin/SHA256SUMS per docs/SETUP.md's documented build recipe, since
control-api and admin-dashboard source changed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeVbMVEiE7XQAkBd7HQgj6
189 lines
6.5 KiB
Go
189 lines
6.5 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,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|