Files
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:31:11 +03:00

777 lines
24 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
// 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"},
}
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{}})
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,
})
})
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
}
f.fipSettleSeconds = req.FIPSettleSeconds
f.historyRetentionCycles = req.HistoryRetentionCycles
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
FIPSettleSeconds: f.fipSettleSeconds,
HistoryRetentionCycles: f.historyRetentionCycles,
})
})
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]})
})
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
}