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>
777 lines
24 KiB
Go
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
|
|
}
|