control-api: every route now carries a mandatory access level (admin / agent / open) in a route table. All /api/v1/admin/* require the admin token; the write calls of validator-agent and prober (self-check, events, results, complete) require a separate static agent token; register, heartbeat and fetching the assignment stay open. Tokens come from env vars, are compared in constant time and never logged. An empty token leaves that level open with a startup warning (backward compatible). validator-agent / prober: apiclient sends the agent token only to control-api. admin-dashboard: login/password (from env) with a stateless HMAC session cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for htmx polls, logout in the sidebar; the dashboard calls control-api with the admin token. Login page layout fixed after review. Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples, e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD, README), plan and review under docs/changes/, bin/ rebuilt with new SHA256SUMS. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
145 lines
4.5 KiB
Go
145 lines
4.5 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
// usedByTag names one check type that references a target group, and
|
|
// whether that check type is currently enabled — rendered as a status pill
|
|
// next to the group so the relationship (invisible before this page had
|
|
// this column) is visible without cross-referencing /check-types by hand.
|
|
type usedByTag struct {
|
|
Name string
|
|
Enabled bool
|
|
}
|
|
|
|
// targetGroupView is targetGroupDTO plus the check types that reference it,
|
|
// resolved server-side from ListCheckTypes so the template stays a plain
|
|
// range with no cross-referencing logic of its own.
|
|
type targetGroupView struct {
|
|
targetGroupDTO
|
|
UsedBy []usedByTag
|
|
}
|
|
|
|
type targetsStats struct {
|
|
Groups int
|
|
TotalTargets int
|
|
CheckTypesUsing int
|
|
Unused int
|
|
}
|
|
|
|
type targetsPageData struct {
|
|
PageData
|
|
Items []targetGroupView
|
|
Stats targetsStats
|
|
}
|
|
|
|
// loadTargetsPage fetches target groups and check types together and
|
|
// resolves the group -> referencing-check-types relationship server-side.
|
|
// A ListCheckTypes failure degrades to "no usage info" rather than hiding
|
|
// the group list — the error still surfaces via the banner.
|
|
func (s *Server) loadTargetsPage(r *http.Request) (targetsPageData, error) {
|
|
groups, err := s.CA.ListTargetGroups(r.Context())
|
|
if err != nil {
|
|
return targetsPageData{}, err
|
|
}
|
|
checkTypes, ctErr := s.CA.ListCheckTypes(r.Context())
|
|
|
|
usedBy := make(map[string][]usedByTag)
|
|
checkTypesUsing := 0
|
|
for _, ct := range checkTypes {
|
|
if len(ct.Targets) > 0 {
|
|
checkTypesUsing++
|
|
}
|
|
for _, g := range ct.Targets {
|
|
usedBy[g] = append(usedBy[g], usedByTag{Name: ct.Name, Enabled: ct.Enabled})
|
|
}
|
|
}
|
|
|
|
items := make([]targetGroupView, len(groups))
|
|
totalTargets := 0
|
|
unused := 0
|
|
for i, g := range groups {
|
|
items[i] = targetGroupView{targetGroupDTO: g, UsedBy: usedBy[g.Name]}
|
|
totalTargets += len(g.Targets)
|
|
if len(usedBy[g.Name]) == 0 {
|
|
unused++
|
|
}
|
|
}
|
|
|
|
return targetsPageData{
|
|
Items: items,
|
|
Stats: targetsStats{
|
|
Groups: len(groups),
|
|
TotalTargets: totalTargets,
|
|
CheckTypesUsing: checkTypesUsing,
|
|
Unused: unused,
|
|
},
|
|
}, ctErr
|
|
}
|
|
|
|
func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
|
|
data, err := s.loadTargetsPage(r)
|
|
data.ActiveNav = "targets"
|
|
data.Banner = bannerFor(err)
|
|
s.renderPage(w, r, "targets_page", data)
|
|
}
|
|
|
|
// renderTargetsTable renders the #targets-table-wrap swap target plus,
|
|
// appended to the same response, an out-of-band update of #targets-stats-wrap
|
|
// (see targets_stats_oob in targets.html) — that block sits above the create
|
|
// form, outside the swap target, so it needs its own refresh to stay live
|
|
// after a create/update/delete. Can't use the shared renderFragment helper
|
|
// here because of that second OOB block; otherwise mirrors it exactly,
|
|
// including always appending error_banner last.
|
|
func (s *Server) renderTargetsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
|
data, listErr := s.loadTargetsPage(r)
|
|
if actionErr == nil {
|
|
actionErr = listErr
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, "targets_table", data); err != nil {
|
|
s.Log.Error("render fragment", "template", "targets_table", "err", err)
|
|
return
|
|
}
|
|
if err := s.tmpl.ExecuteTemplate(w, "targets_stats_oob", data); err != nil {
|
|
s.Log.Error("render targets stats oob", "err", err)
|
|
}
|
|
if err := s.tmpl.ExecuteTemplate(w, "error_banner", bannerFor(actionErr)); err != nil {
|
|
s.Log.Error("render error banner", "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleTargetCreate(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderTargetsTable(w, r, fmt.Errorf("invalid form: %w", err))
|
|
return
|
|
}
|
|
name := r.PostFormValue("name")
|
|
targets := splitList(r.PostFormValue("targets"))
|
|
if name == "" {
|
|
s.renderTargetsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "имя группы обязательно"})
|
|
return
|
|
}
|
|
err := s.CA.PutTargetGroup(r.Context(), name, targets)
|
|
s.renderTargetsTable(w, r, err)
|
|
}
|
|
|
|
func (s *Server) handleTargetUpdate(w http.ResponseWriter, r *http.Request) {
|
|
group := r.PathValue("group")
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderTargetsTable(w, r, fmt.Errorf("invalid form: %w", err))
|
|
return
|
|
}
|
|
targets := splitList(r.PostFormValue("targets"))
|
|
err := s.CA.PutTargetGroup(r.Context(), group, targets)
|
|
s.renderTargetsTable(w, r, err)
|
|
}
|
|
|
|
func (s *Server) handleTargetDelete(w http.ResponseWriter, r *http.Request) {
|
|
group := r.PathValue("group")
|
|
err := s.CA.DeleteTargetGroup(r.Context(), group)
|
|
s.renderTargetsTable(w, r, err)
|
|
}
|