Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
864208238f
commit
b7669c9e41
44 files changed
+4123
-62
No files matched your search
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
|
||||
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||
}
|
||||
}
|
||||
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts)
|
||||
if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/analytics"
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// analyticsRunDTO is one entry of the run selector.
|
||||
type analyticsRunDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at"`
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
Partial int `json:"partial"`
|
||||
Fail int `json:"fail"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
// Total is the number of queue rows of the run, Pending those still being
|
||||
// processed (only an open run has any).
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
type subnetDTO struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type subnetsDTO struct {
|
||||
Subnets []subnetDTO `json:"subnets"`
|
||||
}
|
||||
|
||||
// analyticsCache keeps the computed analysis of finalized runs. An entry is
|
||||
// valid while the run's data version (checks written, results) is unchanged;
|
||||
// the subnet list is part of the key because it changes the grouping.
|
||||
type analyticsCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[int64]analyticsEntry
|
||||
}
|
||||
|
||||
type analyticsEntry struct {
|
||||
version string
|
||||
an *analytics.Analysis
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.DB.ListRuns(r.Context())
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]analyticsRunDTO, 0, len(runs))
|
||||
for _, x := range runs {
|
||||
out = append(out, analyticsRunDTO{
|
||||
ID: x.ID, Kind: x.Kind, State: x.State, StartedAt: x.StartedAt, FinalizedAt: x.FinalizedAt,
|
||||
Addresses: x.Addresses, Pass: x.Pass, Partial: x.Partial, Fail: x.Fail, Cancelled: x.Cancelled,
|
||||
Total: x.Total, Pending: x.Pending,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// analysisFor returns the analysis of a finalized run, from the cache when the
|
||||
// run's data has not changed since it was computed. It writes the error
|
||||
// response itself and returns nil when it cannot.
|
||||
func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid run id")
|
||||
return nil
|
||||
}
|
||||
ctx := r.Context()
|
||||
run, err := s.DB.GetRun(ctx, id)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return nil
|
||||
}
|
||||
if run.State != db.RunFinalized {
|
||||
writeError(w, http.StatusConflict, "run is still open: analytics are available for finished runs")
|
||||
return nil
|
||||
}
|
||||
data, err := s.DB.RunDataVersion(ctx, id)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return nil
|
||||
}
|
||||
subnets, err := s.DB.ListSubnets(ctx)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, x := range subnets {
|
||||
sb.WriteString(x.CIDR + "|" + x.Label + ";")
|
||||
}
|
||||
version := data + "#" + sb.String()
|
||||
|
||||
s.analytics.mu.Lock()
|
||||
defer s.analytics.mu.Unlock()
|
||||
if e, ok := s.analytics.entries[id]; ok && e.version == version {
|
||||
return e.an
|
||||
}
|
||||
an, err := analytics.Load(ctx, s.DB, id)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return nil
|
||||
}
|
||||
if s.analytics.entries == nil {
|
||||
s.analytics.entries = map[int64]analyticsEntry{}
|
||||
}
|
||||
s.analytics.entries[id] = analyticsEntry{version: version, an: an}
|
||||
return an
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
|
||||
if an := s.analysisFor(w, r); an != nil {
|
||||
writeJSON(w, http.StatusOK, an.Report)
|
||||
}
|
||||
}
|
||||
|
||||
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// handleAnalyticsList serves the address table behind one indicator
|
||||
// (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all)
|
||||
// or one ingress error class (kind = error, ?class=...), as JSON or, with
|
||||
// ?format=csv, as a downloadable CSV file.
|
||||
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
|
||||
an := s.analysisFor(w, r)
|
||||
if an == nil {
|
||||
return
|
||||
}
|
||||
kind, class := r.PathValue("kind"), r.URL.Query().Get("class")
|
||||
list, err := an.List(kind, class)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("format") != "csv" {
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM, so Excel opens the file as UTF-8
|
||||
cw := csv.NewWriter(&buf)
|
||||
cw.UseCRLF = true
|
||||
_ = cw.Write(list.Columns)
|
||||
_ = cw.WriteAll(list.Rows)
|
||||
name := kind
|
||||
if kind == analytics.ListError {
|
||||
if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" {
|
||||
name += "-" + slug
|
||||
} else {
|
||||
name += "-class"
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s_run%s.csv"`, name, r.PathValue("id")))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.DB.ListSubnets(r.Context())
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
out := subnetsDTO{Subnets: make([]subnetDTO, 0, len(list))}
|
||||
for _, x := range list {
|
||||
out.Subnets = append(out.Subnets, subnetDTO{CIDR: x.CIDR, Label: x.Label})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleConfigPutSubnets replaces the whole subnet list. The list groups the
|
||||
// addresses on the analytics page; with none configured they group by /24.
|
||||
func (s *Server) handleConfigPutSubnets(w http.ResponseWriter, r *http.Request) {
|
||||
var req subnetsDTO
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
in := make([]db.Subnet, 0, len(req.Subnets))
|
||||
for _, x := range req.Subnets {
|
||||
in = append(in, db.Subnet{CIDR: strings.TrimSpace(x.CIDR), Label: x.Label})
|
||||
}
|
||||
if err := s.DB.ReplaceSubnets(r.Context(), in); err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
s.handleConfigGetSubnets(w, r)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// finishedRun builds one finished run of two addresses through the real
|
||||
// queue paths and returns its id: 9.9.9.1 passes, 9.9.9.2 has a failed ssh.
|
||||
func finishedRun(t *testing.T, d *db.DB) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.1", "9.9.9.2"}, db.RunManual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, addr := range []string{"9.9.9.1", "9.9.9.2"} {
|
||||
ip, err := d.GetIPByAddress(ctx, addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ip, _ = d.GetIP(ctx, ip.ID)
|
||||
put := func(src, typ, target, detail string, ok bool) {
|
||||
if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
|
||||
ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: detail, CheckedAt: db.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put(db.SourceEgress, "https", "https://a.test", "", true)
|
||||
put(db.InboundSource(1), "icmp", addr, "", true)
|
||||
sshOK := addr == "9.9.9.1"
|
||||
put(db.InboundSource(1), "ssh", addr, "dial tcp: i/o timeout", sshOK)
|
||||
verdict := db.ResultPass
|
||||
if !sshOK {
|
||||
verdict = db.ResultPartial
|
||||
}
|
||||
if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rs, err := d.ListRuns(ctx)
|
||||
if err != nil || len(rs) != 1 || rs[0].State != db.RunFinalized {
|
||||
t.Fatalf("expected one finalized run: %+v %v", rs, err)
|
||||
}
|
||||
return rs[0].ID
|
||||
}
|
||||
|
||||
func TestAnalyticsEndpoints(t *testing.T) {
|
||||
fc, d, _, _ := newConfigTestHarness(t)
|
||||
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
|
||||
id := finishedRun(t, d)
|
||||
base := "/api/v1/admin/analytics/runs"
|
||||
|
||||
resp, body := fc.do(http.MethodGet, base, nil)
|
||||
var list []analyticsRunDTO
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &list) != nil || len(list) != 1 ||
|
||||
list[0].State != "finalized" || list[0].Addresses != 2 || list[0].Pass != 1 || list[0].Partial != 1 {
|
||||
t.Fatalf("runs: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id), nil)
|
||||
var rep struct {
|
||||
Summary struct {
|
||||
Addresses int `json:"addresses"`
|
||||
IngressSSHAny int `json:"ingress_ssh_any_failed"`
|
||||
IngressSSHAll int `json:"ingress_ssh_all_failed"`
|
||||
} `json:"summary"`
|
||||
Errors []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
} `json:"errors"`
|
||||
Sites struct {
|
||||
Rows []struct {
|
||||
Site string `json:"site"`
|
||||
} `json:"rows"`
|
||||
} `json:"sites"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Summary.Addresses != 2 ||
|
||||
rep.Summary.IngressSSHAny != 1 || rep.Summary.IngressSSHAll != 1 ||
|
||||
len(rep.Errors) != 1 || rep.Errors[0].Name != "SSH: таймаут" || len(rep.Sites.Rows) != 1 || rep.Sites.Rows[0].Site != "rxmsk" {
|
||||
t.Fatalf("report: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// The list as JSON and as a CSV file with BOM and a download name.
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any", nil)
|
||||
var l struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
|
||||
t.Fatalf("list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any?format=csv", nil)
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") ||
|
||||
!strings.Contains(string(body), "9.9.9.2") || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
|
||||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) {
|
||||
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}}
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil)
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") {
|
||||
t.Fatalf("error csv: %d %v %q", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
|
||||
for path, want := range map[string]int{
|
||||
base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing
|
||||
base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound,
|
||||
base + "/9999": http.StatusNotFound,
|
||||
base + "/abc": http.StatusBadRequest,
|
||||
base + "/9999/lists/ingress_ssh_any": http.StatusNotFound,
|
||||
} {
|
||||
if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != want {
|
||||
t.Errorf("%s: %d %s, want %d", path, resp.StatusCode, body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An open run has no analytics yet.
|
||||
func TestAnalyticsOfOpenRunIsRefused(t *testing.T) {
|
||||
fc, d, _, _ := newConfigTestHarness(t)
|
||||
if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rs, _ := d.ListRuns(context.Background())
|
||||
resp, body := fc.do(http.MethodGet, "/api/v1/admin/analytics/runs/"+itoa64(rs[0].ID), nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("open run: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The result is cached while the run is unchanged and recomputed when a
|
||||
// check of the run is written or the subnet list changes.
|
||||
func TestAnalyticsCacheFollowsData(t *testing.T) {
|
||||
fc, d, _, _ := newConfigTestHarness(t)
|
||||
id := finishedRun(t, d)
|
||||
path := "/api/v1/admin/analytics/runs/" + itoa64(id)
|
||||
subnetsOf := func() []string {
|
||||
_, body := fc.do(http.MethodGet, path, nil)
|
||||
var rep struct {
|
||||
Subnets []struct {
|
||||
CIDR string `json:"cidr"`
|
||||
} `json:"subnets"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, s := range rep.Subnets {
|
||||
out = append(out, s.CIDR)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.9.0/24" {
|
||||
t.Fatalf("without a list addresses group by /24: %v", got)
|
||||
}
|
||||
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "9.9.0.0/16", Label: "девятые"}}})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put subnets: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.0.0/16" {
|
||||
t.Fatalf("a changed subnet list must change the report: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetsConfigEndpoints(t *testing.T) {
|
||||
fc, _, _, _ := newConfigTestHarness(t)
|
||||
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: " 10.1.2.3/24 ", Label: "a"}, {CIDR: "10.0.0.0/8"}}})
|
||||
var got subnetsDTO
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 || got.Subnets[0].CIDR != "10.0.0.0/8" || got.Subnets[1].CIDR != "10.1.2.0/24" {
|
||||
t.Fatalf("put: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if resp, _ = fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "garbage"}}}); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("garbage must be a 400, got %d", resp.StatusCode)
|
||||
}
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/subnets", nil)
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 {
|
||||
t.Fatalf("a rejected list must leave the old one: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRunAndSubnetFilters(t *testing.T) {
|
||||
fc, d, _, _ := newConfigTestHarness(t)
|
||||
id := finishedRun(t, d)
|
||||
// an address outside the run
|
||||
if _, err := d.SubmitIPs(context.Background(), []string{"8.8.8.8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := func(q string) int {
|
||||
t.Helper()
|
||||
resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=50&"+q, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: %d %s", q, resp.StatusCode, body)
|
||||
}
|
||||
var p registryPageResponse
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p.Total
|
||||
}
|
||||
if n := count("run=" + itoa64(id)); n != 2 {
|
||||
t.Errorf("run filter: %d", n)
|
||||
}
|
||||
if n := count("subnet=" + url.QueryEscape("9.9.9.0/24")); n != 2 {
|
||||
t.Errorf("subnet filter: %d", n)
|
||||
}
|
||||
if n := count("run=" + itoa64(id) + "&subnet=" + url.QueryEscape("8.8.8.0/24")); n != 0 {
|
||||
t.Errorf("run and subnet together: %d", n)
|
||||
}
|
||||
for _, bad := range []string{"run=abc", "run=0", "subnet=nonsense"} {
|
||||
if resp, _ := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=5&"+bad, nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: %d, want 400", bad, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -22,7 +23,21 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result")}
|
||||
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result"), Subnet: strings.TrimSpace(q.Get("subnet"))}
|
||||
if filter.Subnet != "" {
|
||||
if _, err := netip.ParsePrefix(filter.Subnet); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subnet "+strconv.Quote(filter.Subnet)+" (a CIDR such as 203.0.113.0/24 is expected)")
|
||||
return
|
||||
}
|
||||
}
|
||||
if v := q.Get("run"); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid run "+strconv.Quote(v))
|
||||
return
|
||||
}
|
||||
filter.RunID = id
|
||||
}
|
||||
if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) {
|
||||
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
|
||||
return
|
||||
|
||||
@@ -65,6 +65,11 @@ func (s *Server) routeTable() []route {
|
||||
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
|
||||
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
|
||||
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
|
||||
{"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, accessAdmin},
|
||||
{"GET /api/v1/admin/analytics/runs/{id}", s.handleAnalyticsRun, accessAdmin},
|
||||
{"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin},
|
||||
{"GET /api/v1/admin/config/subnets", s.handleConfigGetSubnets, accessAdmin},
|
||||
{"PUT /api/v1/admin/config/subnets", s.handleConfigPutSubnets, accessAdmin},
|
||||
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},
|
||||
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
|
||||
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
|
||||
|
||||
@@ -20,6 +20,8 @@ type Server struct {
|
||||
Orch *orchestrator.Orchestrator
|
||||
Log *slog.Logger
|
||||
Auth Authenticator
|
||||
|
||||
analytics analyticsCache
|
||||
}
|
||||
|
||||
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
|
||||
|
||||
Reference in new issue
Block a user