Analytics: compare two finished runs

New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.

Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-04 10:26:37 +03:00
1 parent 2f038f8362
commit 068c10ea1c
28 files changed
+2057 -138

No files matched your search

+48 -1
View File
@@ -456,7 +456,11 @@ func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class
// GetAnalyticsListCSV returns the CSV file of one address table, with the
// file name control-api proposed.
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil)
return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
}
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, "", fmt.Errorf("build request: %w", err)
}
@@ -480,6 +484,49 @@ func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, cla
return body, resp.Header.Get("Content-Disposition"), nil
}
// compareFilter narrows one list of the comparison of two runs.
type compareFilter struct{ Indicator, From, To string }
func analyticsCompareQuery(base, target int64) url.Values {
return url.Values{"base": {strconv.FormatInt(base, 10)}, "target": {strconv.FormatInt(target, 10)}}
}
func analyticsCompareListPath(base, target int64, group string, f compareFilter, csv bool) string {
v := analyticsCompareQuery(base, target)
if f.Indicator != "" {
v.Set("indicator", f.Indicator)
}
if f.From != "" || f.To != "" {
v.Set("from", f.From)
v.Set("to", f.To)
}
if csv {
v.Set("format", "csv")
}
return "/api/v1/admin/analytics/compare/lists/" + url.PathEscape(group) + "?" + v.Encode()
}
// GetAnalyticsCompare returns the comparison of two finished runs (base is the
// older one) as the raw JSON control-api computed.
func (c *client) GetAnalyticsCompare(ctx context.Context, base, target int64) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/compare?"+analyticsCompareQuery(base, target).Encode(), nil, &out)
return out, err
}
// GetAnalyticsCompareList returns one address table (JSON) of the comparison.
func (c *client) GetAnalyticsCompareList(ctx context.Context, base, target int64, group string, f compareFilter) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, analyticsCompareListPath(base, target, group, f, false), nil, &out)
return out, err
}
// GetAnalyticsCompareListCSV returns the CSV file of one comparison table,
// with the file name control-api proposed.
func (c *client) GetAnalyticsCompareListCSV(ctx context.Context, base, target int64, group string, f compareFilter) ([]byte, string, error) {
return c.getCSV(ctx, analyticsCompareListPath(base, target, group, f, true))
}
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
type subnetEntry struct {
CIDR string `json:"cidr"`
+43 -3
View File
@@ -44,9 +44,14 @@ type fakeControlAPI struct {
// 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
runs []analyticsRun
reports map[int64]string
lists map[string]string
// compares is the comparison JSON per "base-target", compareLists the lists
// per "base-target/group[/indicator]".
compares map[string]string
compareLists map[string]string
subnets subnetList
analyticsReqs []string
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
@@ -560,6 +565,41 @@ func (f *fakeControlAPI) handler() http.Handler {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(l))
})
mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")]
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/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
q := r.URL.Query()
key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group")
if ind := q.Get("indicator"); ind != "" {
key += "/" + ind
}
l, ok := f.compareLists[key]
if !ok {
writeAPIErr(w, http.StatusNotFound, "unknown list")
return
}
if q.Get("format") == "csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.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()
+205
View File
@@ -182,6 +182,211 @@ func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
// analyticsComparePageData is the page that compares two finished runs.
type analyticsComparePageData struct {
PageData
RunsA, RunsB []runOption // finished runs; the chosen one of each is selected
HasReport bool
// DataJSON is the page's data for analytics-compare.js (run meta, the
// comparison), HTML-safe JSON.
DataJSON template.JS
}
// compareRunMeta is how analytics-compare.js names one of the two runs.
type compareRunMeta struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Start string `json:"start"`
End string `json:"end"`
Duration string `json:"duration"`
Label string `json:"label"`
}
type compareMeta struct {
Base compareRunMeta `json:"base"`
Target compareRunMeta `json:"target"`
ListURL string `json:"list_url"`
CSVURL string `json:"csv_url"`
}
func newCompareRunMeta(x analyticsRun) compareRunMeta {
m := compareRunMeta{ID: x.ID, Kind: kindLabel(x.Kind), Start: fmtShort(x.StartedAt), End: "—", Duration: "—", Label: runLabel(x)}
if x.FinalizedAt != nil {
m.End = fmtShort(*x.FinalizedAt)
m.Duration = fmtRunDuration(x.FinalizedAt.Sub(x.StartedAt))
}
return m
}
// runIDParam reads a run id from a query value: set is false for an empty
// value, ok for a positive number.
func runIDParam(v string) (id int64, set, ok bool) {
if v == "" {
return 0, false, true
}
id, err := strconv.ParseInt(v, 10, 64)
return id, true, err == nil && id > 0
}
// handleAnalyticsComparePage compares two finished runs: ?base=A (older) and
// ?target=B (newer). By default B is the newest finished run and A the one
// before it; with only target the run before it is the base, with only base
// the newest other run is the target. The state of the page is its address.
func (s *Server) handleAnalyticsComparePage(w http.ResponseWriter, r *http.Request) {
data := analyticsComparePageData{}
data.ActiveNav = "analytics"
runs, err := s.CA.ListAnalyticsRuns(r.Context())
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
var finished []analyticsRun // newest first
for _, x := range runs {
if x.State == "finalized" && x.Addresses > 0 {
finished = append(finished, x)
}
}
if len(finished) < 2 {
data.Banner = bannerData{Message: "Для сравнения нужно минимум два завершённых запуска с адресами.", Client: true}
s.renderPage(w, r, "analytics_compare_page", data)
return
}
find := func(id int64) *analyticsRun {
for i := range finished {
if finished[i].ID == id {
return &finished[i]
}
}
return nil
}
bid, bset, bok := runIDParam(r.URL.Query().Get("base"))
tid, tset, tok := runIDParam(r.URL.Query().Get("target"))
// The default pair is the newest run as B and the one before it as A.
a, b := &finished[1], &finished[0]
warn := ""
switch {
case !bok || !tok:
warn = "Неверный номер запуска."
case bset && tset && bid == tid:
warn = "Выберите два разных запуска."
case bset || tset:
a, b = nil, nil
if bset {
if a = find(bid); a == nil {
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", bid)
}
}
if tset {
if b = find(tid); b == nil {
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", tid)
}
}
if warn == "" {
for i := range finished { // only one end is given: the other is next to it
switch {
case a == nil && i > 0 && finished[i-1].ID == b.ID:
a = &finished[i]
case b == nil && finished[i].ID != a.ID:
b = &finished[i]
}
}
if a == nil || b == nil {
warn = "Для этого запуска нет второго запуска для сравнения."
}
}
}
selA, selB := finished[1].ID, finished[0].ID
if a != nil {
selA = a.ID
}
if b != nil {
selB = b.ID
}
for _, x := range finished {
opt := runOption{ID: x.ID, Label: runLabel(x)}
optA, optB := opt, opt
optA.Selected, optB.Selected = x.ID == selA, x.ID == selB
data.RunsA = append(data.RunsA, optA)
data.RunsB = append(data.RunsB, optB)
}
if warn != "" {
data.Banner = bannerData{Message: warn, Client: true}
s.renderPage(w, r, "analytics_compare_page", data)
return
}
report, err := s.CA.GetAnalyticsCompare(r.Context(), a.ID, b.ID)
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
payload, err := json.Marshal(struct {
Meta compareMeta `json:"meta"`
Report json.RawMessage `json:"report"`
}{compareMeta{Base: newCompareRunMeta(*a), Target: newCompareRunMeta(*b),
ListURL: "/analytics/compare/lists/", CSVURL: "/analytics/compare/csv/"}, report})
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
data.HasReport = true
data.DataJSON = template.JS(payload)
s.renderPage(w, r, "analytics_compare_page", data)
}
// compareParams reads the two run ids and the list filter of a proxied
// comparison list; it writes the 400 itself when an id is missing.
func compareParams(w http.ResponseWriter, r *http.Request) (base, target int64, f compareFilter, ok bool) {
q := r.URL.Query()
base, _ = strconv.ParseInt(q.Get("base"), 10, 64)
target, _ = strconv.ParseInt(q.Get("target"), 10, 64)
if base <= 0 || target <= 0 {
http.Error(w, "base and target are required", http.StatusBadRequest)
return 0, 0, f, false
}
return base, target, compareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}, true
}
// handleAnalyticsCompareList proxies one address table of the comparison as JSON.
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
base, target, f, ok := compareParams(w, r)
if !ok {
return
}
out, err := s.CA.GetAnalyticsCompareList(r.Context(), base, target, r.PathValue("group"), f)
if err != nil {
writeProxyError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(out)
}
// handleAnalyticsCompareCSV proxies the CSV file of one comparison table as a download.
func (s *Server) handleAnalyticsCompareCSV(w http.ResponseWriter, r *http.Request) {
base, target, f, ok := compareParams(w, r)
if !ok {
return
}
body, disposition, err := s.CA.GetAnalyticsCompareListCSV(r.Context(), base, target, r.PathValue("group"), f)
if err != nil {
writeProxyError(w, err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
if disposition != "" {
w.Header().Set("Content-Disposition", disposition)
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func writeProxyError(w http.ResponseWriter, err error) {
status := http.StatusBadGateway
if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 {
@@ -148,6 +148,188 @@ func TestAnalyticsListProxyAndCSV(t *testing.T) {
}
}
// compareWith is the minimal comparison JSON the page needs; new is a marker
// that tells the pairs of runs apart in the page source.
func compareWith(newAddrs string) string {
return `{"runs":{"base":{"id":1,"rechecked":0,"addresses":6440},"target":{"id":2,"rechecked":0,"addresses":900}},` +
`"groups":{"new":` + newAddrs + `,"left":2,"common":3,"changed":1,"same":2},"indicators":[],` +
`"transitions":{"verdicts":["pass","partial","fail"],"matrix":[[0,0,0],[0,0,0],[0,0,0]],"new":[0,0,0],"left":[0,0,0]},"cancelled":{"base":0,"target":0}}`
}
// compareFake is analyticsFake with the comparisons of runs 1 and 2 (run 3 is open).
func compareFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
t.Helper()
fake, ts := analyticsFake(t)
fake.compares = map[string]string{"1-2": compareWith("111"), "2-1": compareWith("222")}
fake.compareLists = map[string]string{
"1-2/changed": `{"group":"changed","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
"1-2/new/verdict_pass": `{"group":"new","indicator":"verdict_pass","columns":["Адрес"],"rows":[["5.6.7.8"]]}`,
}
return fake, ts
}
func compareRequests(fake *fakeControlAPI) []string {
var out []string
for _, r := range fake.analyticsReqs {
if strings.Contains(r, "/compare") {
out = append(out, r)
}
}
return out
}
// By default B is the newest finished run and A the one before it; the open
// run is not offered; the page asks control-api for that pair only.
func TestAnalyticsComparePageDefaultPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare")
for _, want := range []string{
`id="an-base"`, `id="an-target"`, `id="an-swap"`, `id="analytics-data"`, `"new":111`,
`<option value="1" selected>`, `<option value="2" selected>`, // A is run 1, B is run 2
`id="an-dlg"`, `/static/analytics-dialog.js`, `/static/analytics-compare.js`,
"6\u00a0440 адр. · 30% pass",
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Count(page, `<option value="1" selected>`) != 1 || strings.Count(page, `<option value="2" selected>`) != 1 {
t.Fatalf("one run is chosen in each list:\n%s", page)
}
if strings.Contains(page, `value="3"`) {
t.Fatalf("an open run must not be offered:\n%s", page)
}
if got := compareRequests(fake); len(got) != 1 || !strings.Contains(got[0], "base=1") || !strings.Contains(got[0], "target=2") {
t.Fatalf("expected one request for base=1&target=2, got %v", got)
}
}
// The pair in the address: both ends, only the new one (the base is the run
// before it), only the old one (the target is the newest other run).
func TestAnalyticsComparePageExplicitPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare?base=2&target=1")
if !strings.Contains(page, `"new":222`) || !strings.Contains(page, `<option value="2" selected>`) {
t.Fatalf("swapped pair:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=2"); !strings.Contains(page, `"new":111`) {
t.Fatalf("only target=2 must compare with run 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?base=2"); !strings.Contains(page, `"new":222`) {
t.Fatalf("only base=2 must compare with the newest other run, 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=1"); strings.Contains(page, `id="analytics-data"`) ||
!strings.Contains(page, "нет второго запуска") {
t.Fatalf("run 1 is the oldest, nothing to compare it with:\n%s", page)
}
if got := compareRequests(fake); len(got) != 3 {
t.Fatalf("the refused pair must not reach control-api, got %v", got)
}
}
func TestAnalyticsComparePageWarnings(t *testing.T) {
fake, ts := compareFake(t)
for _, c := range []struct{ query, want string }{
{"base=1&target=1", "Выберите два разных запуска"},
{"base=99&target=1", "Запуск 99 не найден или ещё не завершён"},
// the open run
{"base=1&target=3", "Запуск 3 не найден или ещё не завершён"},
{"base=abc&target=1", "Неверный номер запуска"},
{"base=0&target=1", "Неверный номер запуска"},
} {
page := get(t, ts, "/analytics/compare?"+c.query)
if !strings.Contains(page, c.want) || strings.Contains(page, `id="analytics-data"`) || !strings.Contains(page, `id="an-base"`) {
t.Fatalf("%s: expected the warning %q and the run lists without data, got:\n%s", c.query, c.want, page)
}
}
if got := compareRequests(fake); len(got) != 0 {
t.Fatalf("a bad pair must not reach control-api, got %v", got)
}
// Fewer than two finished runs: nothing to compare.
fake, caURL := newFakeControlAPI(t)
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(1, "finalized", 6440, 1962)}
page := get(t, newTestServer(t, caURL), "/analytics/compare")
if !strings.Contains(page, "минимум два завершённых запуска") || strings.Contains(page, `id="an-base"`) {
t.Fatalf("expected the empty state, got:\n%s", page)
}
}
func TestAnalyticsCompareListProxyAndCSV(t *testing.T) {
_, ts := compareFake(t)
fetch := func(path string) (int, http.Header, string) {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp.StatusCode, resp.Header, string(body)
}
code, _, body := fetch("/analytics/compare/lists/changed?base=1&target=2")
if code != http.StatusOK || !strings.Contains(body, `"1.2.3.4"`) {
t.Fatalf("list: %d %s", code, body)
}
// The indicator and the verdicts travel to control-api.
code, _, body = fetch("/analytics/compare/lists/new?base=1&target=2&indicator=verdict_pass")
if code != http.StatusOK || !strings.Contains(body, `"5.6.7.8"`) {
t.Fatalf("list with an indicator: %d %s", code, body)
}
code, header, body := fetch("/analytics/compare/csv/changed?base=1&target=2")
if code != http.StatusOK || !strings.HasPrefix(header.Get("Content-Type"), "text/csv") ||
!strings.Contains(header.Get("Content-Disposition"), `attachment; filename="compare_changed_run1-2.csv"`) {
t.Fatalf("csv: %d %v %s", code, header, body)
}
for _, c := range []struct {
path string
want int
}{
// no runs
{"/analytics/compare/lists/changed", http.StatusBadRequest},
{"/analytics/compare/lists/changed?base=1", http.StatusBadRequest},
{"/analytics/compare/csv/changed?base=abc&target=2", http.StatusBadRequest},
// control-api says unknown list
{"/analytics/compare/lists/nonsense?base=1&target=2", http.StatusNotFound},
{"/analytics/compare/csv/nonsense?base=1&target=2", http.StatusNotFound},
} {
if code, _, _ := fetch(c.path); code != c.want {
t.Errorf("%s: %d, want %d", c.path, code, c.want)
}
}
}
// The single-run page after the dialog moved to analytics-dialog.js: it still
// carries the dialog, loads the shared script before its own, offers the
// comparison, and both scripts are served.
func TestAnalyticsPageUsesSharedDialog(t *testing.T) {
_, ts := analyticsFake(t)
page := get(t, ts, "/analytics")
iDialog, iPage := strings.Index(page, "/static/analytics-dialog.js"), strings.Index(page, "/static/analytics.js")
if iDialog < 0 || iPage < 0 || iDialog > iPage {
t.Fatalf("analytics-dialog.js must come before analytics.js (%d, %d):\n%s", iDialog, iPage, page)
}
for _, want := range []string{`id="an-dlg"`, `id="an-dlg-tbl"`, `id="an-dlg-csv"`, `id="an-tip"`, `href="/analytics/compare?target=2"`} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
for path, want := range map[string]string{
"/static/analytics-dialog.js": "window.AnalyticsDialog =",
"/static/analytics.js": "window.AnalyticsDialog",
"/static/analytics-compare.js": "window.AnalyticsDialog",
} {
if body := get(t, ts, path); !strings.Contains(body, want) {
t.Errorf("%s does not contain %q", path, 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) {
@@ -232,3 +414,15 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
t.Fatalf("a malformed subnet must be ignored: %q", last)
}
}
// The filter of a comparison list goes to control-api as it is.
func TestAnalyticsCompareListPath(t *testing.T) {
got := analyticsCompareListPath(1, 2, "common", compareFilter{Indicator: "verdict_pass", From: "partial", To: "pass"}, true)
want := "/api/v1/admin/analytics/compare/lists/common?base=1&format=csv&from=partial&indicator=verdict_pass&target=2&to=pass"
if got != want {
t.Errorf("path = %q, want %q", got, want)
}
if got := analyticsCompareListPath(3, 4, "new", compareFilter{}, false); got != "/api/v1/admin/analytics/compare/lists/new?base=3&target=4" {
t.Errorf("path = %q", got)
}
}
+3
View File
@@ -32,6 +32,9 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV)
mux.HandleFunc("GET /analytics/compare", s.handleAnalyticsComparePage)
mux.HandleFunc("GET /analytics/compare/lists/{group}", s.handleAnalyticsCompareList)
mux.HandleFunc("GET /analytics/compare/csv/{group}", s.handleAnalyticsCompareCSV)
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
@@ -0,0 +1,136 @@
/* Comparison of two finished runs: renders the comparison embedded as JSON in
#analytics-data (the groups of addresses, the dynamics of the seven
indicators, the verdict transitions) and opens the address list behind each
number in the shared dialog (analytics-dialog.js). The state of the page is
its address: ?base=A&target=B. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, G = R.groups, T = R.transitions;
var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
var NAME = {};
R.indicators.forEach(function (d) { NAME[d.key] = d.name; });
/* ---- what each list says about itself ---- */
var GROUP = {
new: ['Новые адреса', 'Есть в запуске B, в A их не было: прибыли в проект и подключились к анализу. Столбцы показывают состояние в B.'],
left: ['Выбывшие адреса', 'Были в запуске A, в B их нет: вышли из состава проекта. Столбцы показывают состояние в A.'],
common: ['Общие адреса', 'Есть в обоих запусках. Столбцы показывают состояние в A → B.'],
changed: ['Изменившиеся адреса', 'Есть в обоих запусках, принадлежность хотя бы к одному из семи индикаторов в A и в B разная.'],
same: ['Адреса без изменений', 'Есть в обоих запусках, принадлежность ко всем индикаторам та же. Другой набор проваленных целей или площадок изменением не считается.'],
entered: ['Вошли в индикатор', 'Общие адреса, которые в A не входили в этот индикатор, а в B входят.'],
exited: ['Вышли из индикатора', 'Общие адреса, которые в A входили в этот индикатор, а в B не входят.']
};
var HINT_ONE = {
2: 'Вердикт адреса, который система выставила при агрегации, а не итог всех проверок.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — egress-проверок нет.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — ingress-проверок нет.',
5: 'Индикаторы страницы «Аналитика», в которые входит адрес. Один адрес может входить в несколько; вердикт — ровно в один из трёх.'
};
var HINT_PAIR = {
2: 'Вердикт адреса в запуске A и в запуске B.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
5: 'Что изменилось между запусками, по шагам: вердикт, вход в индикатор и выход из него, проваленные цели https и площадки ssh, валидатор. У адреса без изменений в индикаторах — «без изменений», даже если набор проваленных целей другой.'
};
function describe(f) {
var g = GROUP[f.group], ind = f.ind ? '«' + (NAME[f.ind] || f.ind) + '»' : '';
var title = g[0] + (f.ind ? ': ' + (NAME[f.ind] || f.ind) : '') + (f.from ? ' · вердикт ' + f.from + ' → ' + f.to : '');
var note = g[1];
if (f.ind && f.group === 'new') note += ' Только входящие в индикатор ' + ind + ' в запуске B.';
if (f.ind && f.group === 'left') note += ' Только входившие в индикатор ' + ind + ' в запуске A.';
if (f.ind && (f.group === 'common' || f.group === 'changed' || f.group === 'same')) note += ' Только входящие в индикатор ' + ind + ' хотя бы в одном из запусков.';
if (f.from) note += ' Только адреса с вердиктом ' + f.from + ' в A и ' + f.to + ' в B.';
return { title: title, note: note, hints: f.group === 'new' || f.group === 'left' ? HINT_ONE : HINT_PAIR };
}
function listURL(root, f) {
return root + encodeURIComponent(f.group) + '?base=' + M.base.id + '&target=' + M.target.id +
(f.ind ? '&indicator=' + encodeURIComponent(f.ind) : '') +
(f.from ? '&from=' + encodeURIComponent(f.from) + '&to=' + encodeURIComponent(f.to) : '');
}
function openList(f) {
var t = describe(f);
A.load(listURL(M.list_url, f), t.title).then(function (l) {
if (!l) return;
A.fill({
title: t.title, note: t.note, hints: t.hints, scope: 'Запуски', runLabel: 'A — ' + M.base.label + '; B — ' + M.target.label,
cols: l.columns, rows: l.rows, csvURL: listURL(M.csv_url, f)
});
});
}
/* ---- the page ---- */
function renderNote() {
function line(name, m, r, cancelled) {
return '<div>' + esc(name + ': запуск ' + m.id + ', ' + m.kind + ', начало ' + m.start + ', завершён ' + m.end + ', длительность ' + m.duration +
', ' + fmt(r.addresses) + ' адр.' + (r.rechecked ? ', перепроверено внутри запуска: ' + fmt(r.rechecked) : '') +
(cancelled ? ', отменено (не участвуют в сравнении): ' + fmt(cancelled) : '') + '.') + '</div>';
}
$('an-cmp-note').innerHTML = line('A', M.base, R.runs.base, R.cancelled.base) + line('B', M.target, R.runs.target, R.cancelled.target);
}
function renderKpis() {
var GO = ' <span class="an-go">список →</span>';
var tiles = [
['Новые', G.new, 'есть в B, не было в A', 'new'],
['Выбывшие', G.left, 'были в A, нет в B', 'left'],
['Общие', G.common, 'есть в обоих запусках', 'common'],
['Изменились', G.changed, pct(G.changed, G.common) + '% общих адресов', 'changed'],
['Без изменений', G.same, pct(G.same, G.common) + '% общих адресов', 'same']
];
$('an-cmp-kpis').innerHTML = tiles.map(function (t) {
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + fmt(t[1]) + '</div><div class="an-d">' + t[2] + (t[1] ? GO : '') + '</div>';
return t[1]
? '<button type="button" class="an-kpi" data-group="' + t[3] + '" aria-haspopup="dialog">' + inner + '</button>'
: '<div class="an-kpi">' + inner + '</div>';
}).join('');
}
/* a number that opens a list; zero is plain text */
function link(n, attrs, tip) {
return n ? '<button type="button" class="an-lnk" ' + attrs + ' ' + tipAttr(tip) + '>' + fmt(n) + '</button>' : '<span class="an-zero">0</span>';
}
function renderIndicators() {
var COLS = [['new', 'Новые', 'новые адреса, входящие в индикатор'], ['left', 'Выбывшие', 'выбывшие адреса, входившие в индикатор'],
['entered', 'Вошли', 'общие адреса, вошедшие в индикатор'], ['exited', 'Вышли', 'общие адреса, вышедшие из индикатора']];
$('an-cmp-ind').innerHTML = '<thead><tr><th>Индикатор</th><th class="an-r">A</th><th class="an-r">B</th><th class="an-r">Δ</th>' +
COLS.map(function (c) { return '<th class="an-r">' + c[1] + '</th>'; }).join('') + '</tr></thead><tbody>' +
R.indicators.map(function (d) {
var good = d.key === 'verdict_pass' ? d.delta > 0 : d.delta < 0;
var delta = d.delta > 0 ? '+' + fmt(d.delta) : d.delta < 0 ? '−' + fmt(-d.delta) : '0';
return '<tr><td>' + esc(d.name) + '</td><td class="an-num an-r">' + fmt(d.base) + '</td><td class="an-num an-r">' + fmt(d.target) + '</td>' +
'<td class="an-num an-r' + (d.delta ? (good ? ' an-good' : ' an-bad') : '') + '">' + delta + '</td>' +
COLS.map(function (c) {
return '<td class="an-num an-r">' + link(d[c[0]], 'data-group="' + c[0] + '" data-ind="' + d.key + '"', d.name + ': ' + c[2]) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
function renderTransitions() {
var V = T.verdicts;
$('an-cmp-tr').innerHTML = '<thead><tr><th></th>' + V.map(function (v) { return '<th class="an-r">B: ' + esc(v) + '</th>'; }).join('') + '<th class="an-r">нет в B</th></tr></thead><tbody>' +
V.map(function (from, i) {
return '<tr><td>A: ' + esc(from) + '</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.matrix[i][j], 'data-group="common" data-from="' + from + '" data-to="' + to + '"', 'вердикт ' + from + ' в A → ' + to + ' в B') + '</td>';
}).join('') + '<td class="an-num an-r">' + link(T.left[i], 'data-group="left" data-ind="verdict_' + from + '"', 'выбыли, вердикт в A: ' + from) + '</td></tr>';
}).join('') +
'<tr><td>нет в A</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.new[j], 'data-group="new" data-ind="verdict_' + to + '"', 'новые, вердикт в B: ' + to) + '</td>';
}).join('') + '<td class="an-num an-r">—</td></tr></tbody>';
}
/* ---- wiring ---- */
function onOpen(e) {
var b = e.target.closest('[data-group]');
if (b) openList({ group: b.dataset.group, ind: b.dataset.ind || '', from: b.dataset.from || '', to: b.dataset.to || '' });
}
renderNote(); renderKpis(); renderIndicators(); renderTransitions();
['an-cmp-kpis', 'an-cmp-ind', 'an-cmp-tr'].forEach(function (id) { $(id).addEventListener('click', onOpen); });
})();
@@ -0,0 +1,107 @@
/* The address-list dialog of the analytics pages (markup: the "analytics_dialog"
template) and the tooltip that goes with it. A page loads a list with
AnalyticsDialog.load(url, title), then shows it with AnalyticsDialog.fill(m);
copying and the CSV download work on the list shown last. */
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
var cur = null; // the list shown in the dialog
function csvText(cols, rows) {
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
}
function open() {
var d = $('an-dlg');
if (d.open) return;
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
/* m: title, note, scope ("Запуск" by default), runLabel, cols, rows, hints
(column index -> text), cellHints (column index -> function of the value),
dist (HTML above the table), csvURL (the file behind "Скачать CSV"). */
function fill(m) {
cur = m;
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
$('an-dlg-note').textContent = (m.scope || 'Запуск') + ': ' + m.runLabel + '. ' + m.note;
$('an-dlg-dist').hidden = !m.dist;
$('an-dlg-dist').innerHTML = m.dist || '';
var hint = m.hints || {};
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
return '<tr>' + x.map(function (v, i) {
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-dlg-msg').textContent = '';
}
/* Opens the dialog with the title and "Загрузка…", fetches the list as JSON;
resolves with it, or with null (and a message in the dialog) on a failure. */
function load(url, title) {
$('an-dlg-title').textContent = title;
$('an-dlg-note').textContent = 'Загрузка…';
$('an-dlg-dist').hidden = true;
$('an-dlg-tbl').innerHTML = '';
$('an-dlg-msg').textContent = '';
cur = null;
open();
return fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (l) { return l; })
.catch(function (e) {
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
return null;
});
}
function copyCsv() {
var msg = $('an-dlg-msg');
if (!cur) return;
var text = csvText(cur.cols, cur.rows);
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
}
function downloadCsv() {
if (!cur) return;
window.location.href = cur.csvURL;
}
$('an-dlg-csv').addEventListener('click', downloadCsv);
$('an-dlg-copy').addEventListener('click', copyCsv);
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
var tip = $('an-tip');
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
document.addEventListener('mouseover', function (e) {
var t = e.target.closest('[data-tip]');
if (!t) return;
var h = tipHost();
if (tip.parentNode !== h) h.appendChild(tip);
tip.textContent = t.dataset.tip;
tip.style.opacity = 1;
});
document.addEventListener('mousemove', function (e) {
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
tip.style.top = (e.clientY + 16) + 'px';
});
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
window.AnalyticsDialog = { $: $, fmt: fmt, esc: esc, tipAttr: tipAttr, load: load, fill: fill };
})();
+6
View File
@@ -107,6 +107,12 @@ button.an-bar-row:focus-visible { outline: 2px solid var(--accent); outline-offs
.an-dist .an-cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
.an-dist .an-cols20 div { background: var(--an-bar); border-radius: 2px 2px 0 0; min-height: 1px; }
.an-dist .an-lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
/* comparison of two runs: a number that opens a list, and the delta colours */
.an button.an-lnk { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; color: var(--accent-strong); background: none; border: 0; border-bottom: 1px dashed var(--accent); padding: 0 2px; cursor: pointer; }
.an button.an-lnk:hover { background: var(--surface-alt); }
.an button.an-lnk:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.an-zero { color: var(--text-faint); font: 500 12.5px var(--font-mono); }
.an td.an-good { color: var(--success); font-weight: 700; } .an td.an-bad { color: var(--danger); font-weight: 700; }
#an-tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
@media (prefers-reduced-motion: reduce) { #an-tip { transition: none; } }
+7 -94
View File
@@ -1,21 +1,15 @@
/* Analytics page: renders the report of one finished run (embedded as JSON in
#analytics-data) and opens the address lists behind the indicators and the
error classes in a dialog. No other run's data is on the page. */
error classes in the shared dialog (analytics-dialog.js). No other run's data
is on the page. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
function $(id) { return document.getElementById(id); }
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
function pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; }
function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
@@ -211,57 +205,11 @@
}
};
var cur = null; // the list shown in the dialog
function listURL(base, kind, cls) {
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
}
function csvText(cols, rows) {
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
}
function openDialog() {
var d = $('an-dlg');
if (d.open) return;
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
function fillDialog(m) {
cur = m;
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
$('an-dlg-note').textContent = 'Запуск: ' + m.runLabel + '. ' + m.note;
$('an-dlg-dist').hidden = !m.dist;
$('an-dlg-dist').innerHTML = m.dist || '';
var hint = m.hints || {};
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
return '<tr>' + x.map(function (v, i) {
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-dlg-msg').textContent = '';
}
function loadList(kind, cls, meta) {
$('an-dlg-title').textContent = meta.title;
$('an-dlg-note').textContent = 'Загрузка…';
$('an-dlg-dist').hidden = true;
$('an-dlg-tbl').innerHTML = '';
$('an-dlg-msg').textContent = '';
cur = null;
openDialog();
return fetch(listURL(M.list_url, kind, cls), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (l) { return l; })
.catch(function (e) {
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
return null;
});
}
function loadList(kind, cls, meta) { return A.load(listURL(M.list_url, kind, cls), meta.title); }
function runLabel() {
var o = document.getElementById('an-run');
@@ -272,7 +220,7 @@
var meta = LISTS[kind];
loadList(kind, '', meta).then(function (l) {
if (!l) return;
fillDialog({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), file: kind, kind: kind, cls: '' });
A.fill({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), csvURL: listURL(M.csv_url, kind, '') });
});
}
@@ -292,30 +240,16 @@
}).join('') + '</div>' +
'<div class="an-lab20" style="' + grid + '">' + labels.map(function (lab) { return '<span>' + esc(lab.replace(/^v/, '')) + '</span>'; }).join('') + '</div>' +
'<div class="an-chips"><span class="an-tag an-t-n">всего ' + fmt(rows.length) + '</span><span class="an-tag an-t-ok">вердикт pass: ' + fmt(pass) + '</span><span class="an-tag an-t-warn">вердикт partial: ' + fmt(rows.length - pass) + '</span><span class="an-tag an-t-bad">после вердикта: ' + fmt(late) + '</span><span class="an-tag an-t-n">в вердикте: ' + fmt(rows.length - late) + '</span></div>';
fillDialog({
A.fill({
title: 'Ingress: ' + cls,
note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls,
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, csvURL: listURL(M.csv_url, 'error', cls),
hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT },
cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal }
});
});
}
function copyCsv() {
var msg = $('an-dlg-msg');
if (!cur) return;
var text = csvText(cur.cols, cur.rows);
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
}
function downloadCsv() {
if (!cur) return;
window.location.href = listURL(M.csv_url, cur.kind, cur.cls);
}
/* ---- wiring ---- */
function renderAll() {
renderSubnets();
@@ -343,25 +277,4 @@
renderTypes(); renderTargets();
});
$('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); renderTargets(); });
$('an-dlg-csv').addEventListener('click', downloadCsv);
$('an-dlg-copy').addEventListener('click', copyCsv);
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
var tip = $('an-tip');
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
document.addEventListener('mouseover', function (e) {
var t = e.target.closest('[data-tip]');
if (!t) return;
var h = tipHost();
if (tip.parentNode !== h) h.appendChild(tip);
tip.textContent = t.dataset.tip;
tip.style.opacity = 1;
});
document.addEventListener('mousemove', function (e) {
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
tip.style.top = (e.clientY + 16) + 'px';
});
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
})();
+3 -17
View File
@@ -19,6 +19,7 @@
</div>
{{if .HasRun}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics-dialog.js"></script>
<script src="/static/analytics.js"></script>
{{end}}
</body>
@@ -46,6 +47,7 @@
{{end}}
</select>
{{if .NextURL}}<a class="an-btn" href="{{.NextURL}}" aria-label="Следующий запуск">▶</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">▶</span>{{end}}
{{if .HasRun}}<a class="an-btn" href="/analytics/compare?target={{.RunID}}">Сравнить с другим запуском</a>{{end}}
</div>
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
</section>
@@ -114,23 +116,7 @@
<p class="an-note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
</section>
<dialog id="an-dlg" aria-labelledby="an-dlg-title">
<div class="an-dlg">
<h3 id="an-dlg-title"></h3>
<p class="an-note" id="an-dlg-note"></p>
<div class="an-dist" id="an-dlg-dist" hidden></div>
<div class="an-list"><table id="an-dlg-tbl"></table></div>
<div class="an-foot">
<span class="an-note" id="an-dlg-msg" role="status"></span>
<div class="an-acts">
<button class="an-btn" type="button" id="an-dlg-copy">Копировать</button>
<button class="an-btn an-primary" type="button" id="an-dlg-csv">Скачать CSV</button>
<button class="an-btn" type="button" id="an-dlg-close" autofocus>Закрыть</button>
</div>
</div>
</div>
</dialog>
<div id="an-tip" role="tooltip"></div>
{{template "analytics_dialog"}}
{{end}}
</div>
{{end}}
@@ -0,0 +1,75 @@
{{define "analytics_compare_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}
<link rel="stylesheet" href="/static/analytics.css">
</head>
<body>
<div class="bg-grid"></div>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "analytics_compare_content" .}}
</main>
</div>
</div>
{{if .HasReport}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics-dialog.js"></script>
<script src="/static/analytics-compare.js"></script>
{{end}}
</body>
</html>
{{end}}
{{define "analytics_compare_content"}}
<div class="an">
<div class="an-head">
<h1 class="an-title">Сравнение запусков</h1>
<p class="an-sub">Динамика между двумя завершёнными запусками. <a href="/analytics">К аналитике одного запуска</a>.</p>
</div>
{{if .RunsA}}
<section class="an-panel" aria-label="Выбор запусков">
<form class="an-runbar" method="get" action="/analytics/compare">
<label for="an-base">Запуск A (старый)</label>
<select id="an-base" name="base">
{{range .RunsA}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button class="an-btn" type="button" id="an-swap" onclick="var a = document.getElementById('an-base'), b = document.getElementById('an-target'), v = a.value; a.value = b.value; b.value = v; this.form.submit()">Поменять местами</button>
<label for="an-target">Запуск B (новый)</label>
<select id="an-target" name="target">
{{range .RunsB}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button class="an-btn an-primary" type="submit">Сравнить</button>
</form>
{{if .HasReport}}<div class="an-note" id="an-cmp-note"></div>{{end}}
<p class="an-note">«Новые» — адреса, которые есть в B и которых не было в A; «выбывшие» — были в A, в B их нет. Адрес изменился, если его принадлежность к индикаторам в A и B разная. Если запуски разного состава (например, перепроверка части адресов), новых и выбывших будет много: это состав запусков, а не динамика.</p>
</section>
{{end}}
{{if .HasReport}}
<div class="an-kpis" id="an-cmp-kpis"></div>
<section class="an-panel" aria-labelledby="an-h-ind">
<h2 id="an-h-ind">Динамика по индикаторам</h2>
<div class="an-scroll"><table id="an-cmp-ind"></table></div>
<p class="an-note">Δ = B − A = новые − выбывшие + вошли − вышли. Зелёным выделен рост pass и падение остальных индикаторов, красным обратное. Числа в последних четырёх столбцах открывают список адресов.</p>
</section>
<section class="an-panel" aria-labelledby="an-h-tr">
<h2 id="an-h-tr">Переходы вердикта</h2>
<div class="an-scroll"><table id="an-cmp-tr"></table></div>
<p class="an-note">Вердикт адресов, которые есть в обоих запусках: по строкам вердикт в A, по столбцам в B. Строка «нет в A» — новые адреса по вердикту в B, столбец «нет в B» — выбывшие по вердикту в A.</p>
</section>
{{template "analytics_dialog"}}
{{end}}
</div>
{{end}}
@@ -0,0 +1,21 @@
{{/* The address-list dialog and its tooltip, shared by the analytics pages;
analytics-dialog.js drives it. */}}
{{define "analytics_dialog"}}
<dialog id="an-dlg" aria-labelledby="an-dlg-title">
<div class="an-dlg">
<h3 id="an-dlg-title"></h3>
<p class="an-note" id="an-dlg-note"></p>
<div class="an-dist" id="an-dlg-dist" hidden></div>
<div class="an-list"><table id="an-dlg-tbl"></table></div>
<div class="an-foot">
<span class="an-note" id="an-dlg-msg" role="status"></span>
<div class="an-acts">
<button class="an-btn" type="button" id="an-dlg-copy">Копировать</button>
<button class="an-btn an-primary" type="button" id="an-dlg-csv">Скачать CSV</button>
<button class="an-btn" type="button" id="an-dlg-close" autofocus>Закрыть</button>
</div>
</div>
</div>
</dialog>
<div id="an-tip" role="tooltip"></div>
{{end}}