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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-03 18:36:03 +03:00
1 parent 864208238f
commit b7669c9e41
44 files changed
+4123 -62

No files matched your search

+78
View File
@@ -37,6 +37,14 @@ type fakeControlAPI struct {
inboundICMP bool
historyRetentionCycles int
// 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
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).
@@ -469,6 +477,76 @@ func (f *fakeControlAPI) handler() http.Handler {
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]})
})
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/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()