Files
cloud-ip-validator/internal/dashboard/handlers_analytics.go
T
ayurishchevandClaude Sonnet 5.5 ded196ec8d Registry and Analytics: run, subnet, direction and protocol filters, successes-by-target chart
Registry (/registry):
- filters by run (slice by the address's cycle in that run), subnet
  (drop-down of configured subnets), direction (egress/ingress) and
  protocol (icmp, tcp, ssh, https, tls); status in scope is computed over
  the narrowed checks
- chart "successful checks per target (egress) / site (ingress)" when both
  direction and protocol are chosen; a row opens the list of addresses
  (dialog, CSV)
- API: direction/protocol parameters and run in GET /admin/registry,
  GET /admin/registry/breakdown and /breakdown/list
- subnet filter passes ids as one JSON parameter (SQLite variable limit)

Analytics (/analytics):
- subnet filter recomputes the whole page over the addresses of the run
  inside the subnet; only their checks are read; cache per run and subnet
- direction and protocol focus the page; with both set the registry chart
  is shown
- subnet parameter in GET /admin/analytics/runs/{id} and lists (JSON, CSV)

Docs: plans and summaries in docs/changes, README, API, USAGE.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:23:48 +03:00

519 lines
16 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"
"net/url"
"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
// CompareURL is the comparison page with this run as the target.
CompareURL string
// The filter of the page: a subnet recomputes every block, a direction
// and a protocol focus the page on them (and give the chart).
Subnet string
Direction string
Protocol string
Protocols []string
SubnetOptions []subnetOption
Filtered bool // a subnet, direction or protocol is chosen
Breakdown *breakdownView // chart of the direction and protocol; nil without both
// 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"` // registry of the run with the page's direction and protocol; a link adds the subnet
Subnet string `json:"subnet"`
Direction string `json:"direction"`
Protocol string `json:"protocol"`
}
// analyticsURL is the analytics page of a run with the slice filter f (subnet,
// direction, protocol) of the page that links to it.
func analyticsURL(run int64, f registryQuery) string {
v := f.filter()
v.Set("run", strconv.FormatInt(run, 10))
return "/analytics?" + v.Encode()
}
// 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. ?subnet= narrows every
// block to the addresses of that subnet; ?direction=&protocol= focus the page
// (analytics.js) and, both given, add the chart of the registry.
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
}
f := parseSliceFilter(r.URL.Query())
want := f.Run
f.Run = 0 // the links below name the run themselves
// the subnet choices come from the configured list; without it the filter
// still works, just without the choices
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
data.Subnet, data.Direction, data.Protocol = f.Subnet, f.Direction, f.Protocol
data.Protocols = registryProtocols
data.SubnetOptions = registrySubnetOptions(subnets.Subnets, f.Subnet)
data.Filtered = f.Subnet != "" || f.Direction != "" || f.Protocol != ""
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 = analyticsURL(finished[i+1].ID, f)
}
if i > 0 {
data.NextURL = analyticsURL(finished[i-1].ID, f)
}
}
}
data.CompareURL = compareURL(chosen.ID, f)
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID, f.Subnet)
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, Subnet: f.Subnet, Direction: f.Direction, Protocol: f.Protocol,
}
for _, p := range [][2]string{{"direction", f.Direction}, {"protocol", f.Protocol}} {
if p[1] != "" {
meta.Registry += "&" + p[0] + "=" + url.QueryEscape(p[1])
}
}
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)
if f.Direction != "" && f.Protocol != "" {
f.Run = chosen.ID
data.Breakdown = s.registryBreakdown(r, f, f.filter())
}
data.Banner = bannerFor(subnetsErr)
s.renderPage(w, r, "analytics_page", data)
}
// compareURL is the comparison page with a run as the target; it carries the
// slice filter along, as the other links of the page do.
func compareURL(target int64, f registryQuery) string {
v := f.filter()
v.Set("target", strconv.FormatInt(target, 10))
return "/analytics/compare?" + v.Encode()
}
func parseRunParam(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
return id, err == nil && id > 0
}
// analyticsSubnetParam is the ?subnet= of a list request as it came: control-api
// validates it, so a malformed one is an error and never a silently wider list.
func analyticsSubnetParam(r *http.Request) string {
return strings.TrimSpace(r.URL.Query().Get("subnet"))
}
// 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"), analyticsSubnetParam(r))
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"), analyticsSubnetParam(r))
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)
}