426 lines
12 KiB
Go
426 lines
12 KiB
Go
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
|
|
fipSettleSeconds int
|
|
}
|
|
|
|
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("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
|
|
}
|
|
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)
|
|
}
|
|
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})
|
|
})
|
|
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
|
|
}
|
|
f.fipSettleSeconds = req.FIPSettleSeconds
|
|
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{FIPSettleSeconds: f.fipSettleSeconds})
|
|
})
|
|
|
|
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)
|
|
}
|