Files
cloud-ip-validator/internal/dashboard/dashboard_test.go
T
ayurishchevandClaude Sonnet 5.5 068c10ea1c Analytics: compare two finished runs
New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.

Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:26:37 +03:00

908 lines
28 KiB
Go

package dashboard
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"sort"
"strconv"
"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
siteState map[int]string
siteHostname map[int]string
groups map[string][]string
checkTypes map[string]checkTypeDTO
fipSettleSeconds int
inboundPorts []int
inboundICMP bool
historyRetentionCycles int
selfCheckMaxAttempts int
// selfCheckFailedOn is served as self_check_failed_on of the address
// detail and registry history endpoints.
selfCheckFailedOn []string
// Analytics: the run selector, the report JSON per run, the lists per
// "run/kind[/class]" and the subnet list of /settings.
runs []analyticsRun
reports map[int64]string
lists map[string]string
// compares is the comparison JSON per "base-target", compareLists the lists
// per "base-target/group[/indicator]".
compares map[string]string
compareLists map[string]string
subnets subnetList
analyticsReqs []string
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
// directly rather than this fake reimplementing OpenStack floating-IP
// filtering (already covered by internal/orchestrator's own tests).
scanFreeAddresses []string
registry map[string]registryItem
registryChecks map[string][]check
// Scan job state machine (see the scan handlers): POST starts a job that
// stays "running" for scanRunPolls GET polls (0 = finishes at once), then
// ends as done — or as error when scanFinalError is set. scan is the status
// served by GET; tests may also set it directly (with scanPollsLeft == 0 it
// stays as is). scanStartStatus != 0 makes POST fail with that HTTP status.
scan scanStatusDTO
scanRunPolls int
scanPollsLeft int
scanFinalError string
scanStartStatus int
// Requests seen on the list endpoints, for "never loads everything" checks:
// the raw query of every GET /ips (ipsQueries) and the number of GET
// /registry calls without `limit` (bare), plus the sizes of the DeleteIPs /
// SubmitIPs bulk calls.
ipsQueries []string
registryQueries []string
bareIPsCalls int
deleteChunks []int
submitChunks []int
// autoCycle is the state served by /api/v1/admin/auto-cycle*;
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
autoCycle autoCycleDTO
autoCycleDown bool
// authHeaders records the Authorization header of every request served.
authMu sync.Mutex
authHeaders []string
}
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
t.Helper()
f := &fakeControlAPI{
sites: map[int]string{},
siteState: map[int]string{},
siteHostname: map[int]string{},
groups: map[string][]string{},
checkTypes: map[string]checkTypeDTO{},
registry: map[string]registryItem{},
registryChecks: map[string][]check{},
autoCycle: autoCycleDTO{IntervalSeconds: 3600, Phase: "idle"},
selfCheckMaxAttempts: 5,
}
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{}
results := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range f.ips {
byState[ip.State]++
if ip.OverallResult != "" {
results[ip.OverallResult]++
}
}
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators), ResultsByOverall: results})
})
mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.ipsQueries = append(f.ipsQueries, r.URL.RawQuery)
qv := r.URL.Query()
if qv.Get("limit") == "" {
// Legacy shape: the whole queue as a bare array.
f.bareIPsCalls++
writeJSON(w, http.StatusOK, f.ips)
return
}
limit, err := strconv.Atoi(qv.Get("limit"))
if err != nil || limit < 1 || limit > 1000 {
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
return
}
offset, _ := strconv.Atoi(qv.Get("offset"))
var states map[string]bool
if st := qv.Get("state"); st != "" {
states = map[string]bool{}
for _, x := range strings.Split(st, ",") {
states[x] = true
}
}
var matched []ipQueueItem
for _, ip := range f.ips {
if states != nil && !states[ip.State] {
continue
}
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), strings.ToLower(q)) {
continue
}
if res := qv.Get("result"); res != "" && ip.OverallResult != res {
continue
}
matched = append(matched, ip)
}
if qv.Get("order") == "aggregated_at_desc" {
sort.SliceStable(matched, func(i, j int) bool {
a, b := matched[i].AggregatedAt, matched[j].AggregatedAt
if a == nil || b == nil {
return a != nil && b == nil
}
return a.After(*b)
})
} else {
sort.SliceStable(matched, func(i, j int) bool { return matched[i].Sequence < matched[j].Sequence })
}
page := []ipQueueItem{}
if offset < len(matched) {
page = matched[offset:]
if len(page) > limit {
page = page[:limit]
}
}
writeJSON(w, http.StatusOK, ipsPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
})
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{}, SelfCheckFailedOn: f.selfCheckFailedOn})
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
}
f.submitChunks = append(f.submitChunks, len(req.Addresses))
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("DELETE /api/v1/admin/ips/{ip}", 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
}
f.ips = append(f.ips[:idx], f.ips[idx+1:]...)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
mux.HandleFunc("POST /api/v1/admin/ips/delete", 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
}
f.deleteChunks = append(f.deleteChunks, len(req.Addresses))
resp := deleteIPsResponse{}
for _, addr := range req.Addresses {
idx := f.findIP(addr)
if idx < 0 {
resp.NotFound = append(resp.NotFound, addr)
continue
}
f.ips = append(f.ips[:idx], f.ips[idx+1:]...)
resp.Deleted = append(resp.Deleted, addr)
}
writeJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("POST /api/v1/admin/ips/clear", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
resp := clearQueueResponse{}
for _, ip := range f.ips {
resp.Deleted = append(resp.Deleted, ip.IPAddress)
}
resp.Count = len(resp.Deleted)
f.ips = nil
writeJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("GET /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
FIPSettleSeconds: f.fipSettleSeconds,
HistoryRetentionCycles: f.historyRetentionCycles,
SelfCheckMaxAttempts: f.selfCheckMaxAttempts,
})
})
mux.HandleFunc("PUT /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var req orchestratorSettingsDTO
_ = json.NewDecoder(r.Body).Decode(&req)
if req.FIPSettleSeconds < 0 {
writeAPIErr(w, http.StatusBadRequest, "fip_settle_seconds must be >= 0")
return
}
if req.HistoryRetentionCycles < 0 {
writeAPIErr(w, http.StatusBadRequest, "history_retention_cycles must be >= 0")
return
}
if req.SelfCheckMaxAttempts < 1 || req.SelfCheckMaxAttempts > 50 {
writeAPIErr(w, http.StatusBadRequest, "self_check_max_attempts must be in 1..50")
return
}
f.fipSettleSeconds = req.FIPSettleSeconds
f.historyRetentionCycles = req.HistoryRetentionCycles
f.selfCheckMaxAttempts = req.SelfCheckMaxAttempts
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
FIPSettleSeconds: f.fipSettleSeconds,
HistoryRetentionCycles: f.historyRetentionCycles,
SelfCheckMaxAttempts: f.selfCheckMaxAttempts,
})
})
mux.HandleFunc("POST /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.scanStartStatus != 0 {
writeAPIErr(w, f.scanStartStatus, "scan refused")
return
}
if !f.scan.Running {
now := time.Now()
f.scan = scanStatusDTO{State: "listing", Running: true, DryRun: r.URL.Query().Get("dry_run") == "true", StartedAt: &now}
f.scanPollsLeft = f.scanRunPolls
if f.scanPollsLeft == 0 {
f.finishScan()
}
}
writeJSON(w, http.StatusAccepted, f.scan)
})
mux.HandleFunc("GET /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.scan.Running && f.scanPollsLeft > 0 {
f.scanPollsLeft--
if f.scanPollsLeft == 0 {
f.finishScan()
} else {
f.scan.Pages++
}
}
writeJSON(w, http.StatusOK, f.scan)
})
mux.HandleFunc("GET /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("PUT /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
var req struct {
IntervalSeconds *int `json:"interval_seconds"`
MaxRunSeconds *int `json:"max_run_seconds"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.IntervalSeconds != nil && *req.IntervalSeconds < 60 {
writeAPIErr(w, http.StatusBadRequest, "interval_seconds must be >= 60: validation failed")
return
}
if req.MaxRunSeconds != nil && *req.MaxRunSeconds < 0 {
writeAPIErr(w, http.StatusBadRequest, "max_run_seconds must be >= 0: validation failed")
return
}
if req.IntervalSeconds != nil {
f.autoCycle.IntervalSeconds = *req.IntervalSeconds
}
if req.MaxRunSeconds != nil {
f.autoCycle.MaxRunSeconds = *req.MaxRunSeconds
}
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("POST /api/v1/admin/auto-cycle/start", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
if !f.autoCycle.Enabled {
now := time.Now()
f.autoCycle.Enabled = true
f.autoCycle.Phase = "idle"
f.autoCycle.NextRunAt = &now
}
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("POST /api/v1/admin/auto-cycle/stop", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
f.autoCycle.Enabled = false
f.autoCycle.Phase = "idle"
f.autoCycle.NextRunAt = nil
f.autoCycle.LastOutcome = "stopped"
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("GET /api/v1/admin/registry", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
qv := r.URL.Query()
f.registryQueries = append(f.registryQueries, r.URL.RawQuery)
matched := make([]registryItem, 0, len(f.registry))
for _, item := range f.registry {
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(item.IPAddress), strings.ToLower(q)) {
continue
}
if lr := qv.Get("last_result"); lr != "" && item.LastResult != lr {
continue
}
matched = append(matched, item)
}
sort.Slice(matched, func(i, j int) bool { return matched[i].IPAddress < matched[j].IPAddress })
if qv.Get("limit") == "" {
writeJSON(w, http.StatusOK, matched)
return
}
limit, err := strconv.Atoi(qv.Get("limit"))
if err != nil || limit < 1 || limit > 1000 {
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
return
}
offset, _ := strconv.Atoi(qv.Get("offset"))
page := []registryItem{}
if offset < len(matched) {
page = matched[offset:]
if len(page) > limit {
page = page[:limit]
}
}
writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
})
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
addr := r.PathValue("ip")
item, ok := f.registry[addr]
if !ok {
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
return
}
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr], SelfCheckFailedOn: f.selfCheckFailedOn})
})
mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
out := f.subnets
if out.Subnets == nil {
out.Subnets = []subnetEntry{}
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
var in subnetList
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeAPIErr(w, http.StatusBadRequest, err.Error())
return
}
for _, s := range in.Subnets {
if !strings.Contains(s.CIDR, "/") {
writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR")
return
}
}
f.mu.Lock()
defer f.mu.Unlock()
f.subnets = in
writeJSON(w, http.StatusOK, in)
})
mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
out := f.runs
if out == nil {
out = []analyticsRun{}
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
rep, ok := f.reports[id]
if !ok {
writeAPIErr(w, http.StatusNotFound, "run not found")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(rep))
})
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
key := r.PathValue("id") + "/" + r.PathValue("kind")
if c := r.URL.Query().Get("class"); c != "" {
key += "/" + c
}
l, ok := f.lists[key]
if !ok {
writeAPIErr(w, http.StatusNotFound, "unknown list")
return
}
if r.URL.Query().Get("format") == "csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`)
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(l))
})
mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")]
if !ok {
writeAPIErr(w, http.StatusNotFound, "run not found")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(rep))
})
mux.HandleFunc("GET /api/v1/admin/analytics/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
q := r.URL.Query()
key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group")
if ind := q.Get("indicator"); ind != "" {
key += "/" + ind
}
l, ok := f.compareLists[key]
if !ok {
writeAPIErr(w, http.StatusNotFound, "unknown list")
return
}
if q.Get("format") == "csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.csv"`)
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(l))
})
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: f.inboundPorts, ICMP: f.inboundICMP})
})
mux.HandleFunc("PUT /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var req inboundChecksDTO
_ = json.NewDecoder(r.Body).Decode(&req)
for _, p := range req.Ports {
if p < 1 || p > 65535 {
writeAPIErr(w, http.StatusBadRequest, "port out of range")
return
}
}
f.inboundPorts = req.Ports
f.inboundICMP = req.ICMP
writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: f.inboundPorts, ICMP: f.inboundICMP})
})
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, Hostname: f.siteHostname[idx], State: f.siteState[idx]})
}
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
// UpsertSite always resets connection status on write (see its
// control-api doc comment) — mirror that here.
f.siteState[idx] = "unregistered"
f.siteHostname[idx] = ""
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID, State: "unregistered"})
})
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)
delete(f.siteState, idx)
delete(f.siteHostname, 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.authMu.Lock()
f.authHeaders = append(f.authHeaders, r.Header.Get("Authorization"))
f.authMu.Unlock()
mux.ServeHTTP(w, r)
})
}
// finishScan ends the running scan job (caller holds f.mu): the discovered
// free addresses are queued unless it was a dry run, or the job fails with
// scanFinalError.
func (f *fakeControlAPI) finishScan() {
now := time.Now()
f.scan.Running = false
f.scan.FinishedAt = &now
f.scan.Discovered = len(f.scanFreeAddresses)
f.scan.Free = len(f.scanFreeAddresses)
if f.scanFinalError != "" {
f.scan.State = "error"
f.scan.Error = f.scanFinalError
return
}
f.scan.State = "done"
if f.scan.DryRun {
return
}
for _, addr := range f.scanFreeAddresses {
if f.findIP(addr) < 0 {
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: len(f.ips) + 1, CreatedAt: now, UpdatedAt: now})
f.scan.Added++
} else {
f.scan.Reordered++
}
}
}
// seedIPs appends n queued addresses 10.x.y.z (distinct, in sequence order)
// and returns them. Use it for tests that need pages' worth of rows.
func (f *fakeControlAPI) seedIPs(n int) []string {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now()
out := make([]string, 0, n)
base := len(f.ips)
for i := 0; i < n; i++ {
k := base + i + 1
addr := fmt.Sprintf("10.%d.%d.%d", k/65536, (k/256)%256, k%256)
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: k, CreatedAt: now, UpdatedAt: now})
out = append(out, addr)
}
return out
}
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)
}
// newSlowAPI serves an empty JSON object for every request after delay.
func newSlowAPI(t *testing.T, delay time.Duration) string {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
writeJSON(w, http.StatusOK, map[string]interface{}{})
}))
t.Cleanup(ts.Close)
return ts.URL
}