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) }