admin control features and admin dashboard

This commit is contained in:
ayurishchev committed 2026-08-23 20:39:22 +03:00
1 parent c630f13c57
commit 37910e410b
69 files changed
+4959 -400

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
package dashboard
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// apiErr is returned by every client method for a non-2xx response from
// control-api, or for a transport-level failure (control-api unreachable/
// timeout, Status==0). Handlers use it (via errors.As) to render an error
// banner with the right status/severity instead of a raw 500 — see
// writeErrorBanner in render.go.
type apiErr struct {
Status int
Message string
}
func (e *apiErr) Error() string {
if e.Status == 0 {
return fmt.Sprintf("control-api недоступен: %s", e.Message)
}
return fmt.Sprintf("control-api: %d %s", e.Status, e.Message)
}
// client is a thin, dashboard-local JSON client for control-api's
// /api/v1/admin/* surface. It intentionally doesn't reuse
// internal/apiclient.Client (shared by validator-agent/prober): that
// client's Do only returns an opaque error, with no way to recover the
// HTTP status code — which the dashboard needs to render 400 vs 404 vs 409
// vs 5xx differently. Duplicating ~30 lines here avoids widening
// apiclient's contract (and its blast radius on the other two binaries)
// for a need only this package has.
type client struct {
baseURL string
http *http.Client
}
func newClient(baseURL string, timeout time.Duration) *client {
return &client{baseURL: baseURL, http: &http.Client{Timeout: timeout}}
}
func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return &apiErr{Status: 0, Message: err.Error()}
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
msg := string(respBody)
var er errorResponse
if json.Unmarshal(respBody, &er) == nil && er.Error != "" {
msg = er.Error
}
return &apiErr{Status: resp.StatusCode, Message: msg}
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decode response from %s %s: %w", method, path, err)
}
}
return nil
}
func (c *client) Status(ctx context.Context) (statusResponse, error) {
var out statusResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/status", nil, &out)
return out, err
}
func (c *client) ListIPs(ctx context.Context) ([]ipQueueItem, error) {
var out []ipQueueItem
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips", nil, &out)
return out, err
}
func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error) {
var out ipDetailResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, &out)
return out, err
}
// SubmitIPs is the single entry point for both adding new addresses and
// forcing a recheck of already-finished ones — see docs/API.md.
func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) {
var out submitIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
return out, err
}
func (c *client) CancelIP(ctx context.Context, ip string) error {
return c.do(ctx, http.MethodPost, "/api/v1/admin/ips/"+url.PathEscape(ip)+"/cancel", nil, nil)
}
func (c *client) ListValidators(ctx context.Context) ([]validatorDTO, error) {
var out []validatorDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/validators", nil, &out)
return out, err
}
func (c *client) CreateValidator(ctx context.Context, id, osPortID string) error {
body := map[string]string{"validator_id": id, "os_port_id": osPortID}
return c.do(ctx, http.MethodPost, "/api/v1/admin/config/validators", body, nil)
}
func (c *client) UpdateValidator(ctx context.Context, id, osPortID string) error {
body := map[string]string{"os_port_id": osPortID}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/validators/"+url.PathEscape(id), body, nil)
}
func (c *client) DeleteValidator(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/validators/"+url.PathEscape(id), nil, nil)
}
func (c *client) ListSites(ctx context.Context) ([]siteDTO, error) {
var out []siteDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/sites", nil, &out)
return out, err
}
func (c *client) PutSite(ctx context.Context, index int, siteID string) error {
body := map[string]string{"site_id": siteID}
return c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), body, nil)
}
func (c *client) DeleteSite(ctx context.Context, index int) error {
return c.do(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), nil, nil)
}
func (c *client) ListTargetGroups(ctx context.Context) ([]targetGroupDTO, error) {
var out []targetGroupDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/targets", nil, &out)
return out, err
}
func (c *client) PutTargetGroup(ctx context.Context, name string, targets []string) error {
body := map[string][]string{"targets": targets}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/targets/"+url.PathEscape(name), body, nil)
}
func (c *client) DeleteTargetGroup(ctx context.Context, name string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/targets/"+url.PathEscape(name), nil, nil)
}
func (c *client) ListCheckTypes(ctx context.Context) ([]checkTypeDTO, error) {
var out []checkTypeDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/check-types", nil, &out)
return out, err
}
func (c *client) PutCheckType(ctx context.Context, name string, enabled bool, targets []string) error {
body := map[string]interface{}{"enabled": enabled, "targets": targets}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/check-types/"+url.PathEscape(name), body, nil)
}
func (c *client) DeleteCheckType(ctx context.Context, name string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/check-types/"+url.PathEscape(name), nil, nil)
}
+358
View File
@@ -0,0 +1,358 @@
package dashboard
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
)
// fakeControlAPI is a minimal in-memory stand-in for control-api's
// /api/v1/admin/* surface, serving the exact JSON shapes the dashboard's
// client.go expects (see dto.go). It's intentionally simple — enough to
// exercise the dashboard's rendering logic and mutation flows, not a
// re-implementation of control-api's own business rules (that's already
// covered by internal/httpapi's own tests).
type fakeControlAPI struct {
mu sync.Mutex
ips []ipQueueItem
validators []validatorDTO
sites map[int]string
groups map[string][]string
checkTypes map[string]checkTypeDTO
}
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
t.Helper()
f := &fakeControlAPI{
sites: map[int]string{},
groups: map[string][]string{},
checkTypes: map[string]checkTypeDTO{},
}
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
return f, ts.URL
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeAPIErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorResponse{Error: msg})
}
func (f *fakeControlAPI) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/admin/status", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
byState := map[string]int{}
for _, ip := range f.ips {
byState[ip.State]++
}
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators)})
})
mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(w, http.StatusOK, f.ips)
})
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
addr := r.PathValue("ip")
for _, ip := range f.ips {
if ip.IPAddress == addr {
writeJSON(w, http.StatusOK, ipDetailResponse{IP: ip, Checks: []check{}, Events: []event{}})
return
}
}
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
})
mux.HandleFunc("POST /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var req struct {
Addresses []string `json:"addresses"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if len(req.Addresses) == 0 {
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
return
}
resp := submitIPsResponse{}
for _, addr := range req.Addresses {
idx := f.findIP(addr)
if idx < 0 {
now := time.Now()
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now})
resp.Added = append(resp.Added, addr)
continue
}
switch f.ips[idx].State {
case "done", "failed":
f.ips[idx].State = "queued"
f.ips[idx].AttemptNumber++
f.ips[idx].OverallResult = ""
resp.Requeued = append(resp.Requeued, addr)
case "queued":
resp.Reordered = append(resp.Reordered, addr)
default:
resp.SkippedInProgress = append(resp.SkippedInProgress, addr)
}
}
writeJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
addr := r.PathValue("ip")
idx := f.findIP(addr)
if idx < 0 {
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
return
}
if f.ips[idx].State == "done" || f.ips[idx].State == "failed" {
writeAPIErr(w, http.StatusConflict, "already finished")
return
}
f.ips[idx].State = "failed"
f.ips[idx].OverallResult = "cancelled"
now := time.Now()
f.ips[idx].AggregatedAt = &now
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(w, http.StatusOK, f.validators)
})
mux.HandleFunc("POST /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var req validatorDTO
_ = json.NewDecoder(r.Body).Decode(&req)
for _, v := range f.validators {
if v.ValidatorID == req.ValidatorID {
writeAPIErr(w, http.StatusConflict, "already exists")
return
}
}
req.State = "idle"
f.validators = append(f.validators, req)
writeJSON(w, http.StatusCreated, req)
})
mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
id := r.PathValue("id")
var req struct {
OSPortID string `json:"os_port_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
for i, v := range f.validators {
if v.ValidatorID == id {
f.validators[i].OSPortID = req.OSPortID
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
}
writeAPIErr(w, http.StatusNotFound, "unknown validator")
})
mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
id := r.PathValue("id")
for i, v := range f.validators {
if v.ValidatorID == id {
f.validators = append(f.validators[:i], f.validators[i+1:]...)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
}
writeAPIErr(w, http.StatusNotFound, "unknown validator")
})
mux.HandleFunc("GET /api/v1/admin/config/sites", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var out []siteDTO
for idx, id := range f.sites {
out = append(out, siteDTO{Index: idx, SiteID: id})
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var idx int
fmt.Sscanf(r.PathValue("index"), "%d", &idx)
var req struct {
SiteID string `json:"site_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
for existingIdx, id := range f.sites {
if id == req.SiteID && existingIdx != idx {
writeAPIErr(w, http.StatusConflict, "site_id already assigned to another slot")
return
}
}
f.sites[idx] = req.SiteID
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID})
})
mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var idx int
fmt.Sscanf(r.PathValue("index"), "%d", &idx)
delete(f.sites, idx)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/v1/admin/config/targets", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var out []targetGroupDTO
for name, targets := range f.groups {
out = append(out, targetGroupDTO{Name: name, Targets: targets})
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
name := r.PathValue("group")
var req struct {
Targets []string `json:"targets"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if len(req.Targets) == 0 {
writeAPIErr(w, http.StatusBadRequest, "targets must not be empty")
return
}
f.groups[name] = req.Targets
writeJSON(w, http.StatusOK, targetGroupDTO{Name: name, Targets: req.Targets})
})
mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
name := r.PathValue("group")
for _, ct := range f.checkTypes {
for _, g := range ct.Targets {
if g == name {
writeAPIErr(w, http.StatusConflict, "in use by check type "+ct.Name)
return
}
}
}
delete(f.groups, name)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/v1/admin/config/check-types", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var out []checkTypeDTO
for _, ct := range f.checkTypes {
out = append(out, ct)
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
name := r.PathValue("name")
var req struct {
Enabled bool `json:"enabled"`
Targets []string `json:"targets"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
for _, g := range req.Targets {
if _, ok := f.groups[g]; !ok {
writeAPIErr(w, http.StatusBadRequest, "unknown target group "+g)
return
}
}
ct := checkTypeDTO{Name: name, Enabled: req.Enabled, Targets: req.Targets}
f.checkTypes[name] = ct
writeJSON(w, http.StatusOK, ct)
})
mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.checkTypes, r.PathValue("name"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
return mux
}
func (f *fakeControlAPI) findIP(addr string) int {
for i, ip := range f.ips {
if ip.IPAddress == addr {
return i
}
}
return -1
}
func newTestServer(t *testing.T, caURL string) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(Config{
ControlAPIBaseURL: caURL,
ControlAPITimeout: 5 * time.Second,
LastCompletedCount: 20,
OverviewPollIntervalS: 5,
}, log)
if err != nil {
t.Fatalf("new dashboard server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts
}
func get(t *testing.T, ts *httptest.Server, path string) string {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body)
}
func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.Values) string {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(form.Encode()))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body)
}
+116
View File
@@ -0,0 +1,116 @@
package dashboard
import "time"
// Wire shapes for control-api's /api/v1/admin/* surface, defined locally
// rather than importing internal/httpapi's (unexported) DTOs or
// internal/db's models — the same "each binary owns the wire shapes it
// needs" pattern already used by internal/probercore and internal/agentcore.
//
// The read-only list/detail endpoints (ipQueueItem, validator, check,
// event below) mirror internal/db model structs field-for-field: those
// endpoints marshal Go structs with no json tags, so encoding/json matches
// fields by name (case-insensitively) with no tags needed here either.
// Everything else mirrors internal/httpapi/dto_admin.go's snake_case tags.
type statusResponse struct {
TotalIPs int `json:"total_ips"`
IPsByState map[string]int `json:"ips_by_state"`
TotalValidators int `json:"total_validators"`
}
type ipQueueItem struct {
ID int64
IPAddress string
Sequence int
State string
OwnerValidatorID *string
FIPID string
AttemptNumber int
RetryCount int
LeaseExpiresAt *time.Time
EgressComplete bool
Site1Complete bool
Site2Complete bool
Site3Complete bool
OverallResult string
AssignedAt *time.Time
AggregatedAt *time.Time
FIPReleasedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type check struct {
ID int64
IPID int64
IPAddress string
AttemptNumber int
ValidatorID string
Source string
CheckType string
Target string
Success bool
LatencyMS int64
Detail string
CheckedAt time.Time
}
type event struct {
ID int64
SourceType string
SourceID string
IPID *int64
EventType string
Payload string
OccurredAt time.Time
}
type ipDetailResponse struct {
IP ipQueueItem `json:"ip"`
Checks []check `json:"checks"`
Events []event `json:"events"`
}
type validator struct {
ValidatorID string
Hostname string
OSPortID string
State string
CurrentIPID *int64
AgentVersion string
LastHeartbeatAt *time.Time
}
type submitIPsResponse struct {
Added []string `json:"added"`
Requeued []string `json:"requeued"`
Reordered []string `json:"reordered"`
SkippedInProgress []string `json:"skipped_in_progress"`
}
type validatorDTO struct {
ValidatorID string `json:"validator_id"`
OSPortID string `json:"os_port_id"`
State string `json:"state"`
}
type siteDTO struct {
Index int `json:"index"`
SiteID string `json:"site_id"`
}
type targetGroupDTO struct {
Name string `json:"name"`
Targets []string `json:"targets"`
}
type checkTypeDTO struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Targets []string `json:"targets"`
}
type errorResponse struct {
Error string `json:"error"`
}
+23
View File
@@ -0,0 +1,23 @@
package dashboard
import (
"embed"
"io/fs"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// staticSubFS re-roots staticFS so "static/dashboard.css" is served as
// "/dashboard.css" under the /static/ route prefix, instead of
// "/static/static/dashboard.css".
func staticSubFS() fs.FS {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err) // embedded at compile time; a bad path here can't happen at runtime
}
return sub
}
+77
View File
@@ -0,0 +1,77 @@
package dashboard
import (
"fmt"
"net/http"
)
type checkTypesPageData struct {
PageData
Items []checkTypeDTO
Groups []string
}
func (s *Server) loadCheckTypesPage(r *http.Request) (checkTypesPageData, error) {
items, err := s.CA.ListCheckTypes(r.Context())
if err != nil {
return checkTypesPageData{}, err
}
groupDTOs, err := s.CA.ListTargetGroups(r.Context())
if err != nil {
return checkTypesPageData{Items: items}, err
}
groups := make([]string, len(groupDTOs))
for i, g := range groupDTOs {
groups[i] = g.Name
}
return checkTypesPageData{Items: items, Groups: groups}, nil
}
func (s *Server) handleCheckTypesPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadCheckTypesPage(r)
data.ActiveNav = "check-types"
data.Banner = bannerFor(err)
s.renderPage(w, "checktypes_page", data)
}
func (s *Server) renderCheckTypesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListCheckTypes(r.Context())
if actionErr == nil {
actionErr = listErr
}
s.renderFragment(w, "checktypes_table", checkTypesPageData{Items: items}, actionErr)
}
func (s *Server) handleCheckTypeCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderCheckTypesTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
name := r.PostFormValue("name")
if name == "" {
s.renderCheckTypesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "имя типа проверки обязательно"})
return
}
enabled := r.PostFormValue("enabled") != ""
targets := r.PostForm["targets"]
err := s.CA.PutCheckType(r.Context(), name, enabled, targets)
s.renderCheckTypesTable(w, r, err)
}
func (s *Server) handleCheckTypeUpdate(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := r.ParseForm(); err != nil {
s.renderCheckTypesTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
enabled := r.PostFormValue("enabled") == "1"
targets := splitList(r.PostFormValue("targets"))
err := s.CA.PutCheckType(r.Context(), name, enabled, targets)
s.renderCheckTypesTable(w, r, err)
}
func (s *Server) handleCheckTypeDelete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
err := s.CA.DeleteCheckType(r.Context(), name)
s.renderCheckTypesTable(w, r, err)
}
+71
View File
@@ -0,0 +1,71 @@
package dashboard
import (
"fmt"
"net/http"
)
type ipsPageData struct {
PageData
Items []ipQueueItem
}
type ipDetailData struct {
PageData
Detail ipDetailResponse
}
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListIPs(r.Context())
data := ipsPageData{Items: items}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, "ips_page", data)
}
func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
ip := r.PathValue("ip")
detail, err := s.CA.GetIP(r.Context(), ip)
data := ipDetailData{Detail: detail}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, "ip_detail_page", data)
}
// renderIPsTable re-fetches the current queue and renders the ips_table
// fragment, tagging actionErr (if any) on the shared error banner. Called
// after every mutating /ips/* request so the table always reflects true
// current state regardless of whether the mutation itself succeeded.
func (s *Server) renderIPsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListIPs(r.Context())
if actionErr == nil {
actionErr = listErr
}
s.renderFragment(w, "ips_table", ipsPageData{Items: items}, actionErr)
}
func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
addresses := splitList(r.PostFormValue("addresses"))
if len(addresses) == 0 {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "укажите хотя бы один адрес"})
return
}
_, err := s.CA.SubmitIPs(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPRecheck(w http.ResponseWriter, r *http.Request) {
ip := r.PathValue("ip")
_, err := s.CA.SubmitIPs(r.Context(), []string{ip})
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPCancel(w http.ResponseWriter, r *http.Request) {
ip := r.PathValue("ip")
err := s.CA.CancelIP(r.Context(), ip)
s.renderIPsTable(w, r, err)
}
+91
View File
@@ -0,0 +1,91 @@
package dashboard
import (
"net/http"
"sort"
)
type overviewData struct {
PageData
Status statusResponse
CurrentItems []ipQueueItem
LastCompleted []ipQueueItem
Breakdown map[string]int
LastN int
PollSeconds int
}
func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
ctx := r.Context()
status, err := s.CA.Status(ctx)
if err != nil {
return overviewData{}, err
}
ips, err := s.CA.ListIPs(ctx)
if err != nil {
return overviewData{}, err
}
last := lastCompleted(ips, s.Cfg.LastCompletedCount)
return overviewData{
Status: status,
CurrentItems: currentlyChecking(ips),
LastCompleted: last,
Breakdown: resultBreakdown(last),
LastN: s.Cfg.LastCompletedCount,
PollSeconds: s.Cfg.OverviewPollIntervalS,
}, nil
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
data, err := s.loadOverview(r)
data.ActiveNav = "overview"
data.Banner = bannerFor(err)
s.renderPage(w, "overview_page", data)
}
func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request) {
data, err := s.loadOverview(r)
s.renderFragment(w, "overview_fragment", data, err)
}
// currentlyChecking is every IP not yet in a terminal state, ordered by
// queue position — the "текущая проверка" live snapshot. No backend
// concept of a "run" exists; this is computed fresh on every request.
func currentlyChecking(ips []ipQueueItem) []ipQueueItem {
var out []ipQueueItem
for _, ip := range ips {
if ip.State != "done" && ip.State != "failed" {
out = append(out, ip)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence })
return out
}
// lastCompleted returns the n most recently completed (done/failed) IPs by
// AggregatedAt descending — the "последняя завершённая проверка" summary
// window. This is an operational definition, not a real "batch": resubmit
// n if the window size needs tuning (overview.last_completed_count).
func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem {
var done []ipQueueItem
for _, ip := range ips {
if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil {
done = append(done, ip)
}
}
sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) })
if len(done) > n {
done = done[:n]
}
return done
}
// resultBreakdown counts OverallResult values across exactly the given
// items (normally the output of lastCompleted) — pass/partial/fail/cancelled.
func resultBreakdown(items []ipQueueItem) map[string]int {
out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range items {
out[ip.OverallResult]++
}
return out
}
+66
View File
@@ -0,0 +1,66 @@
package dashboard
import (
"fmt"
"net/http"
"strconv"
)
type sitesPageData struct {
PageData
Items []siteDTO // always exactly 3 entries, index 1..3, SiteID=="" for an empty slot
}
// fillSlots pads control-api's response (which only lists assigned slots)
// out to all three fixed slots, so the table always renders 3 rows.
func fillSlots(sites []siteDTO) []siteDTO {
byIndex := make(map[int]string, len(sites))
for _, s := range sites {
byIndex[s.Index] = s.SiteID
}
out := make([]siteDTO, 3)
for i := 0; i < 3; i++ {
out[i] = siteDTO{Index: i + 1, SiteID: byIndex[i+1]}
}
return out
}
func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListSites(r.Context())
data := sitesPageData{Items: fillSlots(items)}
data.ActiveNav = "sites"
data.Banner = bannerFor(err)
s.renderPage(w, "sites_page", data)
}
func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListSites(r.Context())
if actionErr == nil {
actionErr = listErr
}
s.renderFragment(w, "sites_table", sitesPageData{Items: fillSlots(items)}, actionErr)
}
func (s *Server) handleSitePut(w http.ResponseWriter, r *http.Request) {
index, convErr := strconv.Atoi(r.PathValue("index"))
if convErr != nil {
s.renderSitesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "index должен быть числом"})
return
}
if err := r.ParseForm(); err != nil {
s.renderSitesTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
err := s.CA.PutSite(r.Context(), index, r.PostFormValue("site_id"))
s.renderSitesTable(w, r, err)
}
func (s *Server) handleSiteDelete(w http.ResponseWriter, r *http.Request) {
index, convErr := strconv.Atoi(r.PathValue("index"))
if convErr != nil {
s.renderSitesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "index должен быть числом"})
return
}
err := s.CA.DeleteSite(r.Context(), index)
s.renderSitesTable(w, r, err)
}
+59
View File
@@ -0,0 +1,59 @@
package dashboard
import (
"fmt"
"net/http"
)
type targetsPageData struct {
PageData
Items []targetGroupDTO
}
func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListTargetGroups(r.Context())
data := targetsPageData{Items: items}
data.ActiveNav = "targets"
data.Banner = bannerFor(err)
s.renderPage(w, "targets_page", data)
}
func (s *Server) renderTargetsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListTargetGroups(r.Context())
if actionErr == nil {
actionErr = listErr
}
s.renderFragment(w, "targets_table", targetsPageData{Items: items}, actionErr)
}
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)
}
+196
View File
@@ -0,0 +1,196 @@
package dashboard
import (
"strings"
"testing"
"time"
)
func TestOverviewFragment(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
older := now.Add(-time.Hour)
fake.validators = []validatorDTO{{ValidatorID: "v1", State: "idle"}}
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "checking", Sequence: 1, AssignedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "2.2.2.2", State: "done", OverallResult: "pass", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "3.3.3.3", State: "failed", OverallResult: "cancelled", AggregatedAt: &older, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := get(t, ts, "/overview/fragment")
if !strings.Contains(body, "1.1.1.1") {
t.Fatalf("expected in-progress ip 1.1.1.1 in current-checking section, got:\n%s", body)
}
if strings.Contains(body, `href="/ips/1.1.1.1"`) {
// currently-checking rows link to /ips detail too; that's fine, just
// make sure the done one shows up in the "last completed" table.
}
if !strings.Contains(body, "2.2.2.2") || !strings.Contains(body, "3.3.3.3") {
t.Fatalf("expected both completed ips in last-completed section, got:\n%s", body)
}
if !strings.Contains(body, "pass: 1") || !strings.Contains(body, "cancelled: 1") {
t.Fatalf("expected breakdown pass:1 cancelled:1, got:\n%s", body)
}
}
func TestIPsSubmitAddAndForceRecheck(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "9.9.9.9", State: "done", OverallResult: "pass", AttemptNumber: 1, AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
// Add a brand-new address.
body := postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {"1.2.3.4"}})
if !strings.Contains(body, "1.2.3.4") {
t.Fatalf("expected new address in re-rendered table, got:\n%s", body)
}
// Force-recheck the already-done address via the same endpoint.
body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {"9.9.9.9"}})
if !strings.Contains(body, "9.9.9.9") {
t.Fatalf("expected requeued address in table, got:\n%s", body)
}
if fake.ips[0].State != "queued" || fake.ips[0].AttemptNumber != 2 {
t.Fatalf("expected fake control-api state requeued with attempt_number=2, got %+v", fake.ips[0])
}
// Empty submission is a client error, surfaced via the banner, not a 500.
body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {""}})
if !strings.Contains(body, "error-banner-client") {
t.Fatalf("expected client error banner for empty submission, got:\n%s", body)
}
}
func TestIPRecheckSkippedInProgress(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{{IPAddress: "5.5.5.5", State: "checking", UpdatedAt: now, CreatedAt: now}}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/5.5.5.5/recheck", nil)
// SubmitIPs succeeds (200) but the address itself lands in
// skipped_in_progress — the dashboard shouldn't claim success without
// qualification; the table must still show it untouched (still checking).
if !strings.Contains(body, "5.5.5.5") {
t.Fatalf("expected 5.5.5.5 still listed, got:\n%s", body)
}
if fake.ips[0].State != "checking" {
t.Fatalf("expected state untouched (checking), got %s", fake.ips[0].State)
}
}
func TestIPCancel(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{{IPAddress: "7.7.7.7", State: "checking", UpdatedAt: now, CreatedAt: now}}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/7.7.7.7/cancel", nil)
if !strings.Contains(body, "cancelled") {
t.Fatalf("expected cancelled badge after cancel, got:\n%s", body)
}
if fake.ips[0].OverallResult != "cancelled" {
t.Fatalf("expected fake control-api state cancelled, got %+v", fake.ips[0])
}
// Cancelling an already-terminal ip is a 409 from control-api, surfaced
// as a banner, not a crash.
body = postForm(t, ts, "POST", "/ips/7.7.7.7/cancel", nil)
if !strings.Contains(body, "error-banner") {
t.Fatalf("expected error banner for double-cancel, got:\n%s", body)
}
}
func TestValidatorsCRUD(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-1"}})
if !strings.Contains(body, "val-1") || !strings.Contains(body, "port-1") {
t.Fatalf("expected new validator in table, got:\n%s", body)
}
// Conflict: creating the same validator_id again.
body = postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-x"}})
if !strings.Contains(body, "error-banner-client") {
t.Fatalf("expected client error banner on duplicate create, got:\n%s", body)
}
body = postForm(t, ts, "PUT", "/validators/val-1", map[string][]string{"os_port_id": {"port-2"}})
if !strings.Contains(body, "port-2") {
t.Fatalf("expected updated port in table, got:\n%s", body)
}
body = postForm(t, ts, "DELETE", "/validators/val-1", nil)
if strings.Contains(body, "val-1") {
t.Fatalf("expected validator removed after delete, got:\n%s", body)
}
}
func TestSitesPutAndConflict(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/sites/1", map[string][]string{"site_id": {"site-1"}})
if !strings.Contains(body, "site-1") {
t.Fatalf("expected site-1 assigned to slot 1, got:\n%s", body)
}
// Slot page always renders exactly 3 rows, including empty ones — count
// the per-slot PUT forms rather than raw <tr> (the thead row has one too).
page := get(t, ts, "/sites")
if strings.Count(page, `hx-put="/sites/`) != 3 {
t.Fatalf("expected exactly 3 site slot rows, got:\n%s", page)
}
body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}})
if !strings.Contains(body, "error-banner-client") {
t.Fatalf("expected conflict banner assigning a taken site_id to another slot, got:\n%s", body)
}
}
func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/targets", map[string][]string{"name": {"web"}, "targets": {"https://a.test\nhttps://b.test"}})
if !strings.Contains(body, "web") {
t.Fatalf("expected new target group in table, got:\n%s", body)
}
body = postForm(t, ts, "POST", "/check-types", map[string][]string{"name": {"https"}, "enabled": {"1"}, "targets": {"web"}})
if !strings.Contains(body, "https") {
t.Fatalf("expected new check type in table, got:\n%s", body)
}
// Can't delete a target group still referenced by a check type.
body = postForm(t, ts, "DELETE", "/targets/web", nil)
if !strings.Contains(body, "error-banner-client") {
t.Fatalf("expected conflict banner deleting in-use target group, got:\n%s", body)
}
body = postForm(t, ts, "DELETE", "/check-types/https", nil)
if strings.Contains(body, "<td>https</td>") {
t.Fatalf("expected check type removed, got:\n%s", body)
}
body = postForm(t, ts, "DELETE", "/targets/web", nil)
if strings.Contains(body, "<td>web</td>") {
t.Fatalf("expected target group removable once unreferenced, got:\n%s", body)
}
}
func TestControlAPIUnreachable(t *testing.T) {
// Point the dashboard at an address nothing listens on, rather than a
// closed httptest.Server, to get a deterministic connection-refused
// transport failure.
ts := newTestServer(t, "http://127.0.0.1:1")
body := get(t, ts, "/overview")
if !strings.Contains(body, "error-banner-server") {
t.Fatalf("expected server/transport error banner when control-api is unreachable, got:\n%s", body)
}
}
+58
View File
@@ -0,0 +1,58 @@
package dashboard
import (
"fmt"
"net/http"
)
type validatorsPageData struct {
PageData
Items []validatorDTO
}
func (s *Server) handleValidatorsPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListValidators(r.Context())
data := validatorsPageData{Items: items}
data.ActiveNav = "validators"
data.Banner = bannerFor(err)
s.renderPage(w, "validators_page", data)
}
func (s *Server) renderValidatorsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListValidators(r.Context())
if actionErr == nil {
actionErr = listErr
}
s.renderFragment(w, "validators_table", validatorsPageData{Items: items}, actionErr)
}
func (s *Server) handleValidatorCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderValidatorsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
id := r.PostFormValue("validator_id")
osPortID := r.PostFormValue("os_port_id")
if id == "" {
s.renderValidatorsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "validator_id обязателен"})
return
}
err := s.CA.CreateValidator(r.Context(), id, osPortID)
s.renderValidatorsTable(w, r, err)
}
func (s *Server) handleValidatorUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := r.ParseForm(); err != nil {
s.renderValidatorsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
err := s.CA.UpdateValidator(r.Context(), id, r.PostFormValue("os_port_id"))
s.renderValidatorsTable(w, r, err)
}
func (s *Server) handleValidatorDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
err := s.CA.DeleteValidator(r.Context(), id)
s.renderValidatorsTable(w, r, err)
}
+154
View File
@@ -0,0 +1,154 @@
package dashboard
import (
"errors"
"html/template"
"net/http"
"strings"
"time"
)
// Badge is the {class, label} pair rendered as a colored pill — see
// static/dashboard.css for the .badge-* classes.
type Badge struct{ Class, Label string }
func ipBadge(state, result string) Badge {
switch state {
case "done", "failed":
switch result {
case "pass":
return Badge{"badge badge-pass", "pass"}
case "partial":
return Badge{"badge badge-partial", "partial"}
case "cancelled":
return Badge{"badge badge-cancelled", "cancelled"}
default:
return Badge{"badge badge-fail", "fail"}
}
case "queued":
return Badge{"badge badge-queued", "queued"}
default:
return Badge{"badge badge-inprogress", state}
}
}
func validatorBadge(state string) Badge {
switch state {
case "idle":
return Badge{"badge badge-idle", "idle"}
case "unreachable":
return Badge{"badge badge-unreachable", "unreachable"}
case "unregistered":
return Badge{"badge badge-unregistered", "unregistered"}
default:
return Badge{"badge badge-inprogress", 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
}
var funcMap = template.FuncMap{
"ipBadge": ipBadge,
"validatorBadge": validatorBadge,
"fmtTime": fmtTime,
"deref": derefStr,
"join": strings.Join,
}
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)
}
}
+41
View File
@@ -0,0 +1,41 @@
package dashboard
import "net/http"
func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /overview", s.handleOverview)
mux.HandleFunc("GET /overview/fragment", s.handleOverviewFragment)
mux.HandleFunc("GET /ips", s.handleIPsPage)
mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail)
mux.HandleFunc("POST /ips", s.handleIPsSubmit)
mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck)
mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel)
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate)
mux.HandleFunc("DELETE /validators/{id}", s.handleValidatorDelete)
mux.HandleFunc("GET /sites", s.handleSitesPage)
mux.HandleFunc("PUT /sites/{index}", s.handleSitePut)
mux.HandleFunc("DELETE /sites/{index}", s.handleSiteDelete)
mux.HandleFunc("GET /targets", s.handleTargetsPage)
mux.HandleFunc("POST /targets", s.handleTargetCreate)
mux.HandleFunc("PUT /targets/{group}", s.handleTargetUpdate)
mux.HandleFunc("DELETE /targets/{group}", s.handleTargetDelete)
mux.HandleFunc("GET /check-types", s.handleCheckTypesPage)
mux.HandleFunc("POST /check-types", s.handleCheckTypeCreate)
mux.HandleFunc("PUT /check-types/{name}", s.handleCheckTypeUpdate)
mux.HandleFunc("DELETE /check-types/{name}", s.handleCheckTypeDelete)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS())))
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/overview", http.StatusFound)
}
+54
View File
@@ -0,0 +1,54 @@
// Package dashboard implements the admin dashboard's HTTP surface: a
// server-rendered (html/template + htmx + Alpine.js) web UI giving full
// coverage of control-api's /api/v1/admin/* API. It never talks to
// internal/db directly and has no state of its own — every page and
// fragment is computed fresh, on each request, from control-api's API via
// the client in client.go.
package dashboard
import (
"html/template"
"log/slog"
"net/http"
"time"
)
type Config struct {
ControlAPIBaseURL string
ControlAPITimeout time.Duration
LastCompletedCount int
OverviewPollIntervalS int
}
type Server struct {
CA *client
Cfg Config
tmpl *template.Template
Log *slog.Logger
}
func New(cfg Config, log *slog.Logger) (*Server, error) {
tmpl, err := parseTemplates()
if err != nil {
return nil, err
}
return &Server{
CA: newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout),
Cfg: cfg,
tmpl: tmpl,
Log: log,
}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return loggingMiddleware(s.Log, mux)
}
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
log.Debug("request", "method", r.Method, "path", r.URL.Path)
})
}
+59
View File
@@ -0,0 +1,59 @@
/* Small additions Pico's classless build doesn't cover: color-coded state
badges, the error banner, and a compact stat-tile grid for the overview
page. Everything else relies on Pico's classless defaults. */
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
}
.badge-queued { background: #e2e8f0; color: #334155; }
.badge-inprogress { background: #dbeafe; color: #1d4ed8; }
.badge-pass { background: #dcfce7; color: #15803d; }
.badge-partial { background: #ffedd5; color: #c2410c; }
.badge-fail { background: #fee2e2; color: #b91c1c; }
.badge-cancelled { background: #ede9fe; color: #6d28d9; }
.badge-idle { background: #dcfce7; color: #15803d; }
.badge-unreachable { background: #fee2e2; color: #b91c1c; }
.badge-unregistered { background: #e2e8f0; color: #334155; }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-tile {
border: 1px solid var(--pico-muted-border-color, #ddd);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
text-align: center;
}
.stat-tile .value { display: block; font-size: 1.6rem; font-weight: 700; }
.stat-tile .label { display: block; font-size: 0.8rem; opacity: 0.75; }
#error-banner:empty { display: none; }
.error-banner {
padding: 0.6rem 1rem;
margin-bottom: 1rem;
border-radius: 0.4rem;
font-size: 0.9rem;
}
.error-banner-client { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
.error-banner-server { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
nav.dashboard-nav ul { display: flex; gap: 1.25rem; list-style: none; padding: 0; margin: 0 0 1.5rem 0; }
nav.dashboard-nav a[aria-current="page"] { font-weight: 700; text-decoration: underline; }
.actions-cell { white-space: nowrap; }
.actions-cell button { margin-right: 0.35rem; font-size: 0.85rem; padding: 0.25rem 0.6rem; }
.actions-cell button.secondary { --pico-primary-background: #64748b; }
.actions-cell button.contrast { --pico-primary-background: #b91c1c; }
table.compact th, table.compact td { padding: 0.4rem 0.6rem; }
.muted { opacity: 0.65; font-size: 0.85rem; }
+15
View File
@@ -0,0 +1,15 @@
# Vendored frontend assets
Downloaded once and committed as-is — the dashboard binary must work fully
offline, with no runtime CDN dependency (see `docs/PLAN_ADMIN_DASHBOARD.md`).
Do not "update" these files casually; bump deliberately, in their own
commit, if a newer version is actually needed.
| File | Source | Version | Fetched |
|---|---|---|---|
| `htmx.min.js` | https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js | 2.0.10 | 2026-08-23 |
| `alpine.min.js` | https://unpkg.com/alpinejs@3.16.2/dist/cdn.min.js | 3.16.2 | 2026-08-23 |
| `pico.classless.min.css` | https://unpkg.com/@picocss/pico@2.1.1/css/pico.classless.min.css | 2.1.1 | 2026-08-23 |
Licenses: htmx (BSD 2-Clause), Alpine.js (MIT), Pico CSS (MIT) — all
permit vendoring/redistribution unmodified.
+21
View File
@@ -0,0 +1,21 @@
(()=>{var _t=!1,gt=!1,$=[],xt=-1,je=!1,yt=!1;function mr(e){ri(e)}function _r(){yt=!0}function gr(){yt=!1,yr()}function ri(e){$.includes(e)||($.push(e),e._x_schedulerPriority!==void 0&&(je=!0)),yr()}function xr(e){let t=$.indexOf(e);t!==-1&&t>xt&&$.splice(t,1)}function yr(){if(!gt&&!_t){if(yt)return;_t=!0,queueMicrotask(ni)}}function ni(){_t=!1,gt=!0;for(let e=0;e<$.length;e++)je&&ii(e),$[e](),xt=e;$.length=0,xt=-1,je=!1,gt=!1}function ii(e){let t=new Map,r=$.slice(e).sort((n,i)=>oi(n,i,t));for(let n=0;n<r.length;n++)$[e+n]=r[n];je=!1}function oi(e,t,r){return mt(e)?mt(t)?hr(e._x_schedulerPriority.el,r)-hr(t._x_schedulerPriority.el,r)||e._x_schedulerPriority.order-t._x_schedulerPriority.order:-1:mt(t)?1:0}function mt(e){return e._x_schedulerPriority!==void 0}function hr(e,t){if(t.has(e))return t.get(e);let r=0,n=e;for(;e;)r++,e._x_teleportBack?e=e._x_teleportBack:typeof ShadowRoot=="function"&&e.parentNode instanceof ShadowRoot?e=e.parentNode.host:e=e.parentElement;return t.set(n,r),r}var R,P,B,Et,si=0,bt=!0;function br(e){bt=!1,e(),bt=!0}function Er(e){R=e.reactive,B=e.release,P=t=>e.effect(t,{scheduler:r=>{bt?mr(r):r()}}),Et=e.raw}function vt(e){P=e}function vr(e){let t=()=>{};return[(n,i)=>{let o=i?.priority==="structural"?si++:void 0,s=P(n);return o!==void 0&&s!==void 0&&(s._x_schedulerPriority={el:e,order:o}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(s),t=()=>{s!==void 0&&(e._x_effects.delete(s),B(s))},s},()=>{t()}]}function Fe(e,t){let r=!0,n,i,o=P(()=>{let s=e(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{t(s,c)})}n=s,i=a,r=!1});return()=>B(o)}async function wr(e){_r();try{await e(),await Promise.resolve()}finally{gr()}}var Sr=[],Ar=[],Or=[];function Tr(e){Or.push(e)}function ce(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Ar.push(t))}function $e(e){Sr.push(e)}function Be(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function wt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Nr(e){for(e._x_effects?.forEach(xr);e._x_cleanups?.length;)e._x_cleanups.pop()()}var St=new MutationObserver(Nt),At=!1;function Ee(){St.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),At=!0}function Ot(){ai(),St.disconnect(),At=!1}var be=[];function ai(){let e=St.takeRecords();be.push(()=>e.length>0&&Nt(e));let t=be.length;queueMicrotask(()=>{if(be.length===t)for(;be.length>0;)be.shift()()})}function h(e){if(!At)return e();Ot();let t=e();return Ee(),t}var Tt=!1,Ve=[];function Cr(){Tt=!0}function Rr(){Tt=!1,Nt(Ve),Ve=[]}function Nt(e){if(Tt){Ve=Ve.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},f=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(f(),l()):f()}i.forEach((o,s)=>{wt(s,o)}),n.forEach((o,s)=>{Sr.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Ar.forEach(s=>s(o));for(let o of t)o.isConnected&&Or.forEach(s=>s(o));t=null,r=null,n=null,i=null}function He(e){return L(H(e))}function k(e,t,r){return e._x_dataStack=[t,...H(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function H(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?H(e.host):e.parentNode?H(e.parentNode):[]}function L(e){return new Proxy({objects:e},ci)}function Dr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:Dr(Object.getPrototypeOf(e),t)}var ci={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?li:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i;for(let s of e)if(i=Dr(s,t),i)break;i||(i=e[e.length-1]);let o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function li(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function le(e,t=()=>{}){let r=i=>typeof i=="object"&&!Array.isArray(i)&&i!==null,n=(i,o="")=>{Object.entries(Object.getOwnPropertyDescriptors(i)).forEach(([s,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="obLine truncated
${r?'Expression: "'+r+`"
`:""}`,t),setTimeout(()=>{throw e},0)}var ue=!0;function ze(e){let t=ue;ue=!1;let r=e();return ue=t,r}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return Ir(...e)}var Ir=()=>{};function jr(e){Ir=e}var Fr;function Vr(e){Fr=e}function $r(e,t){let r={};q(r,e);let n=[r,...H(e)],i=typeof t=="function"?di(n,t):mi(n,t,e);return Pr.bind(null,e,t,i)}function di(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!ue){ve(r,t,L([n,...e]),i);return}let s=t.apply(L([n,...e]),i);ve(r,s)}}var Dt={};function hi(e,t){if(Dt[e])return Dt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return fe(s,t,e),Promise.resolve()}})();return Dt[e]=o,o}function mi(e,t,r){let n=hi(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=L([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(f=>fe(f,r,t));n.finished?(ve(i,n.result,c,s,r),n.result=void 0):l.then(f=>{ve(i,f,c,s,r)}).catch(f=>fe(f,r,t)).finally(()=>n.result=void 0)}}}function ve(e,t,r,n,i){if(ue&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>ve(e,s,r,n)).catch(s=>fe(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function Br(...e){return Fr(...e)}function Hr(e,t,r={}){let n={};q(n,e);let i=[n,...H(e)],o=L([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&ue?l.apply(o,s):l}}var kt="x-";function N(e=""){return kt+e}function Ur(e){kt=e}var We={};function d(e,t){return We[e]=t,{before(r){if(!We[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=X.indexOf(r);X.splice(n>=0?n:X.indexOf("DEFAULT"),0,e)}}}function zr(e){return Object.keys(We).includes(e)}function Se(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Lt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(qr((o,s)=>n[o]=s)).filter(Yr).map(gi(n,r)).sort(xi).map(o=>_i(e,o))}function Lt(e){return Array.from(e).map(qr()).filter(t=>!Yr(t))}var Mt=!1,we=new Map,Wr=Symbol();function Kr(e){Mt=!0;let t=Symbol();Wr=t,we.set(t,[]);let r=()=>{for(;we.get(t).length;)we.get(t).shift()();we.delete(t)},n=()=>{Mt=!1,r()};e(r),n()}function Rt(e){let t=[],r=a=>t.push(a),[n,i]=vr(e);return t.push(i),[{Alpine:U,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function _i(e,t){let r=()=>{},n=We[t.type]||r,[i,o]=Rt(e);Be(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),Mt?we.get(Wr).push(n):n())};return s.runCleanups=o,s}var Ke=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),qe=e=>e;function qr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=Gr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var Gr=[];function pe(e){Gr.push(e)}function Yr({name:e}){return Jr().test(e)}var Jr=()=>new RegExp(`^${kt}([^:^.]+)\\b`);function gi(e,t){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(Jr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var Pt="DEFAULT",X=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",Pt,"teleport"];function xi(e,t){let r=X.indexOf(e.type)===-1?Pt:e.type,n=X.indexOf(t.type)===-1?Pt:t.type;return X.indexOf(r)-X.indexOf(n)}function Z(e,t,r={},n={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)I(n,t,!1),n=n.nextElementSibling}function w(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Xr=!1;function Zr(){Xr&&w("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Xr=!0,document.body||w("Unable to initialize. TryinLine truncated
/*! Bundled license information:
@vue/shared/dist/shared.esm-bundler.js:
(**
* @vue/shared v3.5.41
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
@vue/reactivity/dist/reactivity.esm-bundler.js:
(**
* @vue/reactivity v3.5.41
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**)
*/
+1
View File
@@ -0,0 +1 @@
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.protLine truncated
@@ -0,0 +1,4 @@
@charset "UTF-8";/*!
* Pico CSS ✨ v2.1.1 (https://picocss.com)
* Copyright 2019-2025 - Licensed under MIT
*/:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}aLine truncated
@@ -0,0 +1,65 @@
{{define "checktypes_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "checktypes_content" .}}
</main>
</body>
</html>
{{end}}
{{define "checktypes_content"}}
<h1>Типы проверок</h1>
<p class="muted">Тип проверки (https/icmp/ssh/...) ссылается на одну или несколько групп целей —
именно развёрнутый список валидатор получает в задании.</p>
{{if not .Groups}}
<p class="muted">Сначала создайте хотя бы одну <a href="/targets">группу целей</a>.</p>
{{else}}
<form hx-post="/check-types" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="ct-name">Имя типа проверки</label>
<input id="ct-name" type="text" name="name" placeholder="https" required>
<label><input type="checkbox" name="enabled" value="1" checked> включён</label>
<fieldset>
<legend>Группы целей</legend>
{{range .Groups}}
<label><input type="checkbox" name="targets" value="{{.}}"> {{.}}</label>
{{end}}
</fieldset>
<button type="submit">Создать тип проверки</button>
</form>
{{end}}
<div id="checktypes-table-wrap">
{{template "checktypes_table" .}}
</div>
{{end}}
{{define "checktypes_table"}}
<table role="grid">
<thead><tr><th>Имя</th><th>Включён</th><th>Группы целей</th><th>Действия</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Name}}</td>
<td>{{if .Enabled}}✅{{else}}—{{end}}</td>
<td>{{join .Targets ", "}}</td>
<td class="actions-cell">
<form hx-put="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" style="display:inline">
<input type="hidden" name="targets" value="{{join .Targets ","}}">
<button type="submit" class="secondary" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
{{if .Enabled}}Выключить{{else}}Включить{{end}}
</button>
</form>
<button class="contrast" hx-delete="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить тип проверки {{.Name}}?">Удалить</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .Items}}<p class="muted">Типов проверок нет.</p>{{end}}
{{end}}
@@ -0,0 +1,56 @@
{{define "ip_detail_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ip_detail_content" .}}
</main>
</body>
</html>
{{end}}
{{define "ip_detail_content"}}
<p><a href="/ips">&larr; К очереди</a></p>
<h1>{{.Detail.IP.IPAddress}}</h1>
{{$b := ipBadge .Detail.IP.State .Detail.IP.OverallResult}}
<p>
<span class="{{$b.Class}}">{{$b.Label}}</span>
· попытка {{.Detail.IP.AttemptNumber}} (повторов: {{.Detail.IP.RetryCount}})
· валидатор {{deref .Detail.IP.OwnerValidatorID}}
</p>
<p class="muted">
создан: {{fmtTime .Detail.IP.CreatedAt}} · назначен: {{fmtTime .Detail.IP.AssignedAt}} ·
агрегирован: {{fmtTime .Detail.IP.AggregatedAt}} · FIP освобождён: {{fmtTime .Detail.IP.FIPReleasedAt}}
</p>
<h2>Проверки (попытка {{.Detail.IP.AttemptNumber}})</h2>
{{if .Detail.Checks}}
<table class="compact" role="grid">
<thead><tr><th>Источник</th><th>Тип</th><th>Цель</th><th>Успех</th><th>Задержка, мс</th><th>Время</th><th>Детали</th></tr></thead>
<tbody>
{{range .Detail.Checks}}
<tr>
<td>{{.Source}}</td><td>{{.CheckType}}</td><td>{{.Target}}</td>
<td>{{if .Success}}✅{{else}}❌{{end}}</td>
<td>{{.LatencyMS}}</td><td>{{fmtTime .CheckedAt}}</td><td>{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}<p class="muted">Проверок пока нет.</p>{{end}}
<h2>События</h2>
{{if .Detail.Events}}
<table class="compact" role="grid">
<thead><tr><th>Время</th><th>Тип</th><th>Источник</th><th>Данные</th></tr></thead>
<tbody>
{{range .Detail.Events}}
<tr><td>{{fmtTime .OccurredAt}}</td><td>{{.EventType}}</td><td>{{.SourceType}} {{.SourceID}}</td><td>{{.Payload}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}<p class="muted">Событий нет.</p>{{end}}
{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "ips_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ips_content" .}}
</main>
</body>
</html>
{{end}}
{{define "ips_content"}}
<h1>Очередь IP</h1>
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
<textarea id="addresses" name="addresses" rows="3" placeholder="203.0.113.10&#10;203.0.113.11"></textarea>
<button type="submit">Добавить / принудительно перепроверить</button>
<p class="muted">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается.</p>
</form>
<div id="ips-table-wrap">
{{template "ips_table" .}}
</div>
{{end}}
{{define "ips_table"}}
<table role="grid">
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th>Действия</th></tr></thead>
<tbody>
{{range .Items}}
{{$b := ipBadge .State .OverallResult}}
{{$terminal := or (eq .State "done") (eq .State "failed")}}
<tr>
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
<td>{{deref .OwnerValidatorID}}</td>
<td>{{.AttemptNumber}}</td>
<td>{{fmtTime .UpdatedAt}}</td>
<td class="actions-cell">
{{if $terminal}}
<button class="secondary" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML">Перепроверить</button>
{{else}}
<button class="contrast" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
{{end}}
+39
View File
@@ -0,0 +1,39 @@
{{/* html/template requires {{template "name"}} names to be string
literals, not dynamic pipelines — so a single "layout" wrapper that
dispatches to a per-page content block by name isn't possible within
one shared template set. Instead, layout.html provides small named
partials (html_head, page_header, banner_inner, error_banner) that
each page's *_page template (see e.g. overview.html) assembles
around its own *_content block explicitly. A few lines of
boilerplate are duplicated per page as a result — see any *_page
define for the pattern. */}}
{{define "html_head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud IP Validator — Admin</title>
<link rel="stylesheet" href="/static/vendor/pico.classless.min.css">
<link rel="stylesheet" href="/static/dashboard.css">
<script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/alpine.min.js" defer></script>
{{end}}
{{define "page_header"}}
<header>
<nav class="dashboard-nav">
<strong>Cloud IP Validator</strong>
<ul>
<li><a href="/overview" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}}>Обзор</a></li>
<li><a href="/ips" {{if eq .ActiveNav "ips"}}aria-current="page"{{end}}>Очередь IP</a></li>
<li><a href="/validators" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}}>Валидаторы</a></li>
<li><a href="/sites" {{if eq .ActiveNav "sites"}}aria-current="page"{{end}}>Площадки</a></li>
<li><a href="/targets" {{if eq .ActiveNav "targets"}}aria-current="page"{{end}}>Цели</a></li>
<li><a href="/check-types" {{if eq .ActiveNav "check-types"}}aria-current="page"{{end}}>Типы проверок</a></li>
</ul>
</nav>
</header>
{{end}}
{{define "banner_inner"}}{{if .Message}}<div class="error-banner {{if .Client}}error-banner-client{{else}}error-banner-server{{end}}">{{.Message}}</div>{{end}}{{end}}
{{define "error_banner"}}<div id="error-banner" hx-swap-oob="true">{{template "banner_inner" .}}</div>{{end}}
@@ -0,0 +1,20 @@
{{define "overview_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "overview_content" .}}
</main>
</body>
</html>
{{end}}
{{define "overview_content"}}
<h1>Обзор</h1>
<div id="overview-live" hx-get="/overview/fragment" hx-trigger="every {{.PollSeconds}}s" hx-swap="innerHTML">
{{template "overview_fragment" .}}
</div>
{{end}}
@@ -0,0 +1,55 @@
{{define "overview_fragment"}}
<div class="stat-grid">
<div class="stat-tile"><span class="value">{{.Status.TotalIPs}}</span><span class="label">всего IP</span></div>
<div class="stat-tile"><span class="value">{{.Status.TotalValidators}}</span><span class="label">валидаторов</span></div>
{{range $state, $count := .Status.IPsByState}}
<div class="stat-tile"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div>
{{end}}
</div>
<h2>Текущая проверка</h2>
{{if .CurrentItems}}
<table class="compact" role="grid">
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead>
<tbody>
{{range .CurrentItems}}
{{$b := ipBadge .State .OverallResult}}
<tr>
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
<td>{{deref .OwnerValidatorID}}</td>
<td>{{.AttemptNumber}}</td>
<td>{{fmtTime .AssignedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Сейчас нет адресов в обработке.</p>
{{end}}
<h2>Последние {{.LastN}} завершённых</h2>
<p class="muted">
pass: {{index .Breakdown "pass"}} ·
partial: {{index .Breakdown "partial"}} ·
fail: {{index .Breakdown "fail"}} ·
cancelled: {{index .Breakdown "cancelled"}}
</p>
{{if .LastCompleted}}
<table class="compact" role="grid">
<thead><tr><th>Адрес</th><th>Итог</th><th>Завершено</th></tr></thead>
<tbody>
{{range .LastCompleted}}
{{$b := ipBadge .State .OverallResult}}
<tr>
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
<td>{{fmtTime .AggregatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Пока ничего не завершено.</p>
{{end}}
{{end}}
+47
View File
@@ -0,0 +1,47 @@
{{define "sites_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "sites_content" .}}
</main>
</body>
</html>
{{end}}
{{define "sites_content"}}
<h1>Площадки</h1>
<p class="muted">Ровно три слота (1, 2, 3) — жёсткое ограничение схемы БД control-api.
Пустой слот — площадка не используется, входящие проверки для неё не ожидаются.</p>
<div id="sites-table-wrap">
{{template "sites_table" .}}
</div>
{{end}}
{{define "sites_table"}}
<table role="grid">
<thead><tr><th>Слот</th><th>site_id</th><th>Действия</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Index}}</td>
<td>
<form hx-put="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" style="display:flex;gap:.5rem;margin:0">
<input type="text" name="site_id" value="{{.SiteID}}" style="margin:0" required>
<button type="submit" class="secondary" style="margin:0">Сохранить</button>
</form>
</td>
<td class="actions-cell">
{{if .SiteID}}
<button class="contrast" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Освободить слот {{.Index}}?">Очистить</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "targets_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "targets_content" .}}
</main>
</body>
</html>
{{end}}
{{define "targets_content"}}
<h1>Цели проверки</h1>
<p class="muted">Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.</p>
<form hx-post="/targets" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="target-name">Имя новой группы</label>
<input id="target-name" type="text" name="name" placeholder="web" required>
<label for="target-list">Цели (по одной на строку или через запятую)</label>
<textarea id="target-list" name="targets" rows="2" placeholder="https://hub.docker.com&#10;https://github.com" required></textarea>
<button type="submit">Создать группу</button>
</form>
<div id="targets-table-wrap">
{{template "targets_table" .}}
</div>
{{end}}
{{define "targets_table"}}
<table role="grid">
<thead><tr><th>Группа</th><th>Цели</th><th>Действия</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Name}}</td>
<td>
<form hx-put="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" style="display:flex;gap:.5rem;margin:0">
<textarea name="targets" rows="2" style="margin:0">{{join .Targets "\n"}}</textarea>
<button type="submit" class="secondary" style="margin:0">Сохранить</button>
</form>
</td>
<td class="actions-cell">
<button class="contrast" hx-delete="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить группу {{.Name}}?">Удалить</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .Items}}<p class="muted">Групп целей нет.</p>{{end}}
{{end}}
@@ -0,0 +1,54 @@
{{define "validators_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "validators_content" .}}
</main>
</body>
</html>
{{end}}
{{define "validators_content"}}
<h1>Валидаторы</h1>
<form hx-post="/validators" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<div class="grid">
<input type="text" name="validator_id" placeholder="validator_id" required>
<input type="text" name="os_port_id" placeholder="os_port_id (Neutron port ID)" required>
<button type="submit">Добавить</button>
</div>
</form>
<div id="validators-table-wrap">
{{template "validators_table" .}}
</div>
{{end}}
{{define "validators_table"}}
<table role="grid">
<thead><tr><th>ID</th><th>OS Port ID</th><th>Состояние</th><th>Действия</th></tr></thead>
<tbody>
{{range .Items}}
{{$b := validatorBadge .State}}
<tr>
<td>{{.ValidatorID}}</td>
<td>
<form hx-put="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" style="display:flex;gap:.5rem;margin:0">
<input type="text" name="os_port_id" value="{{.OSPortID}}" style="margin:0">
<button type="submit" class="secondary" style="margin:0">Сохранить</button>
</form>
</td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
<td class="actions-cell">
<button class="contrast" hx-delete="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить валидатор {{.ValidatorID}}?">Удалить</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .Items}}<p class="muted">Валидаторов нет.</p>{{end}}
{{end}}
+21
View File
@@ -0,0 +1,21 @@
package dashboard
import "strings"
// splitList parses a textarea/hidden-input value into a clean list,
// accepting entries separated by newlines and/or commas interchangeably
// (different forms in this package use one or the other) and dropping
// blank entries from stray whitespace.
func splitList(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ','
})
out := make([]string, 0, len(fields))
for _, f := range fields {
f = strings.TrimSpace(f)
if f != "" {
out = append(out, f)
}
}
return out
}