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>
This commit is contained in:
1 parent
864208238f
commit
b7669c9e41
44 files changed
+4123
-62
No files matched your search
@@ -0,0 +1,254 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user