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>
519 lines
16 KiB
Go
519 lines
16 KiB
Go
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)
|
||
}
|