Files
cloud-ip-validator/internal/dashboard/handlers_analytics.go
T
ayurishchevandClaude Sonnet 5.5 b7669c9e41 Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an
address enters an idle queue, takes everything submitted or re-checked while it
is open and is finalized when all its addresses are done; a re-check after that
opens a new run, so results of different runs never mix. check_runs,
run_results (one result per address and run, with the verdict and the expected
and stored check counts), subnets, run_id on ip_queue and checks. Existing data
is split into runs at pauses of more than an hour; ingress checks get the
validator that held the address (also at write time from now on).

Analytics (internal/analytics): figures computed from the stored checks of the
latest cycle of each address in the run, as facts next to the verdict: summary,
reasons of partial, data quality, subnets, targets and the subnet x target
matrix by check type, ingress by site, error classes, validators, and the
address lists behind the indicators and error classes. API: analytics runs,
report, lists (JSON or CSV), subnet list; run and subnet filters for the
registry.

Dashboard: /analytics matching the approved mockup (run selector, indicators
with address lists and CSV, error-class dialogs, drill-down to the registry),
subnet list on /settings. Sidebar: the control-api link state, theme toggle and
logout moved to the top, the three dots next to the logo removed, sections
grouped.

Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the
updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:36:03 +03:00

255 lines
7.1 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)
}
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)
}