Files
cloud-ip-validator/internal/dashboard/handlers_analytics.go
T
ayurishchevandClaude Sonnet 5.5 068c10ea1c 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>
2026-10-04 10:26:37 +03:00

460 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dashboard
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"time"
)
// runOption is one entry of the run selector.
type runOption struct {
ID int64
Label string
Disabled bool
Selected bool
}
type analyticsPageData struct {
PageData
Runs []runOption
RunID int64
PrevURL string // older run, "" when there is none
NextURL string // newer run
// DataJSON is the page's data for analytics.js (run meta, labels, report),
// HTML-safe JSON.
DataJSON template.JS
HasRun bool
}
// analyticsMeta is what analytics.js needs besides the report.
type analyticsMeta struct {
RunID int64 `json:"run_id"`
Kind string `json:"kind"`
Start string `json:"start"`
End string `json:"end"`
Duration string `json:"duration"`
Rechecked int `json:"rechecked"`
ListURL string `json:"list_url"`
CSVURL string `json:"csv_url"`
Registry string `json:"registry_url"`
}
// handleAnalyticsPage renders the analytics of one finished run: ?run=ID, by
// default the newest finished run. The run selector lists every run, the open
// one disabled; nothing of any other run is on the page.
func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
data := analyticsPageData{}
data.ActiveNav = "analytics"
runs, err := s.CA.ListAnalyticsRuns(r.Context())
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_page", data)
return
}
want, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
var chosen *analyticsRun
for i := range runs { // newest first
if runs[i].State != "finalized" || runs[i].Addresses == 0 {
continue
}
if want == 0 || runs[i].ID == want {
chosen = &runs[i]
break
}
}
var finished []analyticsRun // newest first
for _, x := range runs {
if x.State == "finalized" && x.Addresses > 0 {
finished = append(finished, x)
}
}
for _, x := range runs {
if x.State == "finalized" && x.Addresses == 0 {
continue // nothing to show for it, hide
}
opt := runOption{ID: x.ID, Label: runLabel(x), Disabled: x.State != "finalized"}
if chosen != nil && x.ID == chosen.ID {
opt.Selected = true
}
data.Runs = append(data.Runs, opt)
}
if chosen == nil {
if want != 0 {
data.Banner = bannerData{Message: fmt.Sprintf("Запуск %d не найден или ещё не завершён.", want), Client: true}
}
s.renderPage(w, r, "analytics_page", data)
return
}
data.RunID = chosen.ID
for i, x := range finished {
if x.ID == chosen.ID {
if i+1 < len(finished) {
data.PrevURL = "/analytics?run=" + strconv.FormatInt(finished[i+1].ID, 10)
}
if i > 0 {
data.NextURL = "/analytics?run=" + strconv.FormatInt(finished[i-1].ID, 10)
}
}
}
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID)
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_page", data)
return
}
var info struct {
Run struct {
Rechecked int `json:"rechecked"`
} `json:"run"`
}
_ = json.Unmarshal(report, &info)
id := strconv.FormatInt(chosen.ID, 10)
meta := analyticsMeta{
RunID: chosen.ID, Kind: kindLabel(chosen.Kind),
Start: fmtShort(chosen.StartedAt), Duration: "—", Rechecked: info.Run.Rechecked,
ListURL: "/analytics/lists/",
CSVURL: "/analytics/csv/",
Registry: "/registry?run=" + id,
}
if chosen.FinalizedAt != nil {
meta.End = fmtShort(*chosen.FinalizedAt)
meta.Duration = fmtRunDuration(chosen.FinalizedAt.Sub(chosen.StartedAt))
}
payload, err := json.Marshal(struct {
Meta analyticsMeta `json:"meta"`
Report json.RawMessage `json:"report"`
}{meta, report})
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_page", data)
return
}
data.HasRun = true
data.DataJSON = template.JS(payload)
s.renderPage(w, r, "analytics_page", data)
}
func parseRunParam(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
return id, err == nil && id > 0
}
// handleAnalyticsList proxies one address table of a run as JSON.
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
id, ok := parseRunParam(r)
if !ok {
http.Error(w, "run is required", http.StatusBadRequest)
return
}
out, err := s.CA.GetAnalyticsList(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
if err != nil {
writeProxyError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(out)
}
// handleAnalyticsCSV proxies the CSV file of one address table as a download.
func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
id, ok := parseRunParam(r)
if !ok {
http.Error(w, "run is required", http.StatusBadRequest)
return
}
body, disposition, err := s.CA.GetAnalyticsListCSV(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
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)
}
// 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 {
status = ae.Status
}
http.Error(w, err.Error(), status)
}
func kindLabel(kind string) string {
if kind == "auto" {
return "авто"
}
return "ручной"
}
// groupThousands writes n with a space between thousands: 6440 -> "6 440".
func groupThousands(n int) string {
s := strconv.Itoa(n)
if len(s) <= 3 {
return s
}
var b strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteString(" ")
}
b.WriteRune(c)
}
return b.String()
}
// fmtShort is a run timestamp as the page shows it: 02.10.2026 13:47.
func fmtShort(t time.Time) string { return t.Local().Format("02.01.2006 15:04") }
// fmtDuration is "8 ч 42 мин", "42 мин", "под минуту".
func fmtRunDuration(d time.Duration) string {
m := int(d.Round(time.Minute) / time.Minute)
switch {
case m <= 0:
return "меньше минуты"
case m < 60:
return fmt.Sprintf("%d мин", m)
}
return fmt.Sprintf("%d ч %d мин", m/60, m%60)
}
// runLabel is the text of a run in the selector, e.g.
// "02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass"; an open run reads
// "03.10 15:30 → идёт · ручной · 120 из 800 · недоступен".
func runLabel(x analyticsRun) string {
start := x.StartedAt.Local().Format("02.01 15:04")
if x.State != "finalized" {
return fmt.Sprintf("%s → идёт · %s · %s из %s · недоступен", start, kindLabel(x.Kind),
groupThousands(x.Total-x.Pending), groupThousands(x.Total))
}
end := "—"
if x.FinalizedAt != nil {
e := x.FinalizedAt.Local()
if e.Format("02.01") == x.StartedAt.Local().Format("02.01") {
end = e.Format("15:04")
} else {
end = e.Format("02.01 15:04")
}
}
pass := 0
if x.Addresses > 0 {
pass = int(float64(x.Pass)/float64(x.Addresses)*100 + 0.5)
}
return fmt.Sprintf("%s → %s · %s · %s адр. · %d%% pass", start, end, kindLabel(x.Kind), groupThousands(x.Addresses), pass)
}