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>
235 lines
9.2 KiB
Go
235 lines
9.2 KiB
Go
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)
|
||
}
|
||
}
|