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

@@ -0,0 +1,234 @@
package dashboard
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func fakeRun(id int64, state string, addresses, pass int) analyticsRun {
start := time.Date(2026, 10, 2, 13, 47, 0, 0, time.UTC)
end := start.Add(8*time.Hour + 42*time.Minute)
r := analyticsRun{ID: id, Kind: "manual", State: state, StartedAt: start, Addresses: addresses, Pass: pass,
Partial: addresses - pass, Total: addresses}
if state == "finalized" {
r.FinalizedAt = &end
} else {
r.Pending = 80
r.Total = addresses + r.Pending
}
return r
}
// reportWith is the minimal report JSON the page needs; addresses is a marker
// that tells the runs apart in the page source.
func reportWith(addresses string) string {
return `{"run":{"rechecked":0},"summary":{"addresses":` + addresses + `,"pass":1,"partial":0,"fail":0,"cancelled":0,` +
`"egress_ok":1,"ingress_ok":1,"egress_https_any_failed":0,"egress_https_all_failed":0,"egress_https_all_targets_failed":0,` +
`"ingress_ssh_any_failed":0,"ingress_ssh_all_failed":0,"addresses_per_minute":1},"reasons":[],"quality":{},"subnets":[],` +
`"targets":{"types":[],"targets":[],"failed":{}},"matrix":{},"sites":{"types":[],"rows":[]},"errors":[],"validators":[]}`
}
func analyticsFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
t.Helper()
fake, caURL := newFakeControlAPI(t)
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(2, "finalized", 900, 300), fakeRun(1, "finalized", 6440, 1962)}
fake.reports = map[int64]string{1: reportWith("6440"), 2: reportWith("900")}
fake.lists = map[string]string{
"1/egress_https_any": `{"kind":"egress_https_any","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
"1/error/SSH: таймаут": `{"kind":"error","class":"SSH: таймаут","columns":["Адрес"],"rows":[["1.2.3.4"]]}`,
}
return fake, newTestServer(t, caURL)
}
// The page shows one run, by default the newest finished one, and asks
// control-api for that run only; the selector lists every run, the open one
// disabled.
func TestAnalyticsPageShowsOneRun(t *testing.T) {
fake, ts := analyticsFake(t)
page := get(t, ts, "/analytics")
for _, want := range []string{
`id="an-run"`, `id="analytics-data"`, `"addresses":900`, // newest finished run (2)
"идёт · ручной · 120 из 200 · недоступен",
"6 440 адр. · 30% pass", // run 1's option, from the run list
`/analytics?run=1`, // the older run is one step back
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Contains(page, `"addresses":6440`) {
t.Fatalf("the data of run 1 is on the page of run 2")
}
if !strings.Contains(page, `value="3" disabled`) {
t.Fatalf("the open run must be listed but disabled:\n%s", page)
}
for _, r := range fake.analyticsReqs {
if strings.Contains(r, "/runs/1") || strings.Contains(r, "/runs/3") {
t.Fatalf("the page must request only the chosen run, got %v", fake.analyticsReqs)
}
}
other := get(t, ts, "/analytics?run=1")
if !strings.Contains(other, `"addresses":6440`) || strings.Contains(other, `"addresses":900`) {
t.Fatalf("run 1 page must carry only run 1's data:\n%s", other)
}
}
func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/analytics")
if !strings.Contains(page, "Запусков проверки пока нет") || strings.Contains(page, `id="analytics-data"`) {
t.Fatalf("expected the empty state, got:\n%s", page)
}
_, ts = analyticsFake(t)
for _, run := range []string{"99", "3"} { // unknown, and the open one
page = get(t, ts, "/analytics?run="+run)
if !strings.Contains(page, "не найден или ещё не завершён") {
t.Fatalf("run %s: expected a warning, got:\n%s", run, page)
}
}
}
func TestAnalyticsListProxyAndCSV(t *testing.T) {
_, ts := analyticsFake(t)
resp, err := http.Get(ts.URL + "/analytics/lists/egress_https_any?run=1")
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"1.2.3.4"`) {
t.Fatalf("list: %d %s", resp.StatusCode, body)
}
q := url.Values{"run": {"1"}, "class": {"SSH: таймаут"}}
resp, err = http.Get(ts.URL + "/analytics/lists/error?" + q.Encode())
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"class":"SSH: таймаут"`) {
t.Fatalf("error list: %d %s", resp.StatusCode, body)
}
resp, err = http.Get(ts.URL + "/analytics/csv/egress_https_any?run=1")
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="egress_https_any_run1.csv"`) {
t.Fatalf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
}
for path, want := range map[string]int{
"/analytics/lists/egress_https_any": http.StatusBadRequest, // no run
"/analytics/csv/egress_https_any?run=abc": http.StatusBadRequest,
"/analytics/lists/nonsense?run=1": http.StatusNotFound, // control-api says unknown list
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != want {
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
}
}
}
// The sidebar: no window-chrome dots next to the logo, the Analytics entry,
// and the link state, theme toggle and logout above the navigation.
func TestSidebarSessionBlockOnTop(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
cookie := login(t, ts)
_, page := doReq(t, ts, reqOpts{path: "/overview", cookie: cookie})
if strings.Contains(page, "brand-chrome") {
t.Fatalf("the three dots next to the logo are back")
}
iBrand := strings.Index(page, `class="brand"`)
iAPI := strings.Index(page, `class="session-api"`)
iLogout := strings.Index(page, `action="/logout"`)
iNav := strings.Index(page, `class="nav-groups"`)
iFoot := strings.Index(page, "sidebar-foot")
if !(iBrand >= 0 && iBrand < iAPI && iAPI < iLogout && iLogout < iNav) || iFoot >= 0 {
t.Fatalf("expected brand < control-api state < logout < navigation (and no old footer): %d %d %d %d foot=%d", iBrand, iAPI, iLogout, iNav, iFoot)
}
for _, link := range []string{`href="/analytics"`, `href="/registry"`, `href="/settings"`} {
if !strings.Contains(page, link) {
t.Fatalf("missing nav link %s", link)
}
}
if strings.Contains(page, "pulse-dot down") {
t.Fatalf("the link state must be fine while control-api answers")
}
}
// The link indicator turns red when control-api cannot be reached.
func TestSidebarShowsLostControlAPI(t *testing.T) {
ts := newTestServer(t, "http://127.0.0.1:1") // nothing listens there
page := get(t, ts, "/overview")
if !strings.Contains(page, "pulse-dot down") || !strings.Contains(page, "нет связи") {
t.Fatalf("expected the lost-link indicator, got:\n%s", page)
}
}
func TestSettingsSubnetsForm(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"83.166.248.0/21 москва\n\n 10.0.0.0/8\n"}})
if len(fake.subnets.Subnets) != 2 ||
fake.subnets.Subnets[0] != (subnetEntry{CIDR: "83.166.248.0/21", Label: "москва"}) ||
fake.subnets.Subnets[1] != (subnetEntry{CIDR: "10.0.0.0/8"}) {
t.Fatalf("subnets saved: %+v", fake.subnets)
}
if !strings.Contains(body, "83.166.248.0/21 москва") || !strings.Contains(body, "10.0.0.0/8") {
t.Fatalf("the saved list must come back in the form:\n%s", body)
}
body = postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"not-a-cidr"}})
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "not a CIDR") {
t.Fatalf("expected the validation error in the banner:\n%s", body)
}
}
// The drill-down from the analytics page: run and subnet go to control-api,
// come back as hidden fields and a visible chip; a malformed subnet is dropped.
func TestRegistryDrillDownFromAnalytics(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry?run=2&subnet="+url.QueryEscape("10.0.0.0/24"))
if len(fake.registryQueries) == 0 {
t.Fatal("no registry request")
}
last := fake.registryQueries[len(fake.registryQueries)-1]
if !strings.Contains(last, "run=2") || !strings.Contains(last, "subnet=10.0.0.0%2F24") {
t.Fatalf("control-api request %q lacks the run or subnet", last)
}
for _, want := range []string{`name="run" value="2"`, `name="subnet" value="10.0.0.0/24"`, "запуск 2", "подсеть 10.0.0.0/24", "сбросить фильтр"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
}
page = get(t, ts, "/registry?subnet=garbage")
last = fake.registryQueries[len(fake.registryQueries)-1]
if strings.Contains(last, "subnet=") || strings.Contains(page, "сбросить фильтр") {
t.Fatalf("a malformed subnet must be ignored: %q", last)
}
}