2026-09-23 09:52:01 +03:00
|
|
|
package dashboard
|
|
|
|
|
|
2026-09-23 11:58:53 +03:00
|
|
|
import (
|
2026-10-06 14:23:48 +03:00
|
|
|
"errors"
|
2026-09-23 11:58:53 +03:00
|
|
|
"net/http"
|
2026-10-03 18:36:03 +03:00
|
|
|
"net/netip"
|
2026-10-01 19:31:11 +03:00
|
|
|
"net/url"
|
2026-10-03 18:36:03 +03:00
|
|
|
"strconv"
|
2026-09-23 11:58:53 +03:00
|
|
|
"strings"
|
|
|
|
|
)
|
2026-09-23 09:52:01 +03:00
|
|
|
|
2026-10-06 14:23:48 +03:00
|
|
|
// Values of the registry's direction and protocol filters; same as
|
|
|
|
|
// db.RegistryFilter's Level and Family.
|
|
|
|
|
var (
|
|
|
|
|
registryDirections = []string{"egress", "ingress"}
|
|
|
|
|
registryProtocols = []string{"icmp", "tcp", "ssh", "https", "tls"}
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-23 09:52:01 +03:00
|
|
|
type registryPageData struct {
|
|
|
|
|
PageData
|
2026-10-01 19:31:11 +03:00
|
|
|
Items []registryItem
|
|
|
|
|
Query string
|
|
|
|
|
StatusFilter string
|
2026-10-06 14:23:48 +03:00
|
|
|
Run int64 // data slice: the address's cycle in this run (also the drill-down from analytics)
|
2026-10-03 18:36:03 +03:00
|
|
|
Subnet string
|
2026-10-06 14:23:48 +03:00
|
|
|
Direction string
|
|
|
|
|
Protocol string
|
|
|
|
|
Protocols []string
|
|
|
|
|
Scoped bool // direction or protocol is set
|
|
|
|
|
Runs []runOption
|
|
|
|
|
SubnetOptions []subnetOption
|
|
|
|
|
Breakdown *breakdownView // nil unless a direction or protocol is chosen
|
|
|
|
|
AnalyticsURL string // the analytics page of Run with the same subnet, direction and protocol
|
2026-10-01 19:31:11 +03:00
|
|
|
Page, PerPage int
|
|
|
|
|
Total int
|
|
|
|
|
Pager pagerData
|
|
|
|
|
PerPageOptions []int
|
2026-09-23 09:52:01 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type registryDetailData struct {
|
|
|
|
|
PageData
|
|
|
|
|
History registryHistoryResponse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleRegistryPage lists every address ever submitted to the check
|
|
|
|
|
// queue, with a summary of its accumulated check history — the durable
|
|
|
|
|
// record that survives an address being deleted from /ips and later
|
2026-09-23 11:58:53 +03:00
|
|
|
// re-added. See internal/db/migrations/0007_ip_registry.sql. Optional
|
|
|
|
|
// ?q=&status= query params narrow the list by address substring and by
|
2026-10-06 14:23:48 +03:00
|
|
|
// LastResult, ?run=&subnet=&direction=&protocol= by run, subnet and the
|
|
|
|
|
// direction/protocol of the checks, and ?page=&per_page= select a page — all
|
|
|
|
|
// applied server-side (control-api's ListRegistryPage), so only the visible
|
|
|
|
|
// rows are transferred. The run and subnet choices come from the analytics
|
|
|
|
|
// run list and the configured subnets; if either list is unavailable the
|
|
|
|
|
// filters still work, just without those choices.
|
2026-09-23 09:52:01 +03:00
|
|
|
func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
2026-10-06 14:23:48 +03:00
|
|
|
query := parseRegistryQuery(r)
|
|
|
|
|
q, status, run, subnet, direction, protocol := query.Q, query.LastResult, query.Run, query.Subnet, query.Direction, query.Protocol
|
2026-10-01 19:31:11 +03:00
|
|
|
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
|
|
|
|
page := parsePage(r.URL.Query().Get("page"))
|
2026-10-06 14:23:48 +03:00
|
|
|
query.Limit, query.Offset = perPage, (page-1)*perPage
|
2026-10-01 19:31:11 +03:00
|
|
|
|
|
|
|
|
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
|
|
|
|
if err == nil {
|
|
|
|
|
if clamped := clampPage(page, res.Total, perPage); clamped != page {
|
|
|
|
|
page = clamped
|
|
|
|
|
query.Offset = (page - 1) * perPage
|
|
|
|
|
res, err = s.CA.ListRegistryPage(r.Context(), query)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
params := url.Values{}
|
|
|
|
|
if q != "" {
|
|
|
|
|
params.Set("q", q)
|
|
|
|
|
}
|
|
|
|
|
if status != "" {
|
|
|
|
|
params.Set("status", status)
|
|
|
|
|
}
|
2026-10-03 18:36:03 +03:00
|
|
|
if run > 0 {
|
|
|
|
|
params.Set("run", strconv.FormatInt(run, 10))
|
|
|
|
|
}
|
|
|
|
|
if subnet != "" {
|
|
|
|
|
params.Set("subnet", subnet)
|
|
|
|
|
}
|
2026-10-06 14:23:48 +03:00
|
|
|
if direction != "" {
|
|
|
|
|
params.Set("direction", direction)
|
|
|
|
|
}
|
|
|
|
|
if protocol != "" {
|
|
|
|
|
params.Set("protocol", protocol)
|
|
|
|
|
}
|
|
|
|
|
// A filter/pager request from htmx swaps only #registry-table-wrap
|
|
|
|
|
// (hx-select), so the run and subnet choices of the form are not needed.
|
|
|
|
|
// A history-restore fetch needs the full page.
|
|
|
|
|
var runs []analyticsRun
|
|
|
|
|
var subnets subnetList
|
|
|
|
|
var runsErr, subnetsErr error
|
|
|
|
|
if r.Header.Get("HX-Request") != "true" || r.Header.Get("HX-History-Restore-Request") == "true" {
|
|
|
|
|
runs, runsErr = s.CA.ListAnalyticsRuns(r.Context())
|
|
|
|
|
subnets, subnetsErr = s.CA.GetSubnets(r.Context())
|
|
|
|
|
}
|
2026-09-23 11:58:53 +03:00
|
|
|
data := registryPageData{
|
2026-10-03 18:36:03 +03:00
|
|
|
Run: run,
|
|
|
|
|
Subnet: subnet,
|
2026-10-06 14:23:48 +03:00
|
|
|
Direction: direction,
|
|
|
|
|
Protocol: protocol,
|
|
|
|
|
Protocols: registryProtocols,
|
|
|
|
|
Scoped: direction != "" || protocol != "",
|
|
|
|
|
Runs: registryRunOptions(runs, run),
|
|
|
|
|
SubnetOptions: registrySubnetOptions(subnets.Subnets, subnet),
|
2026-10-01 19:31:11 +03:00
|
|
|
Items: res.Items,
|
|
|
|
|
Query: q,
|
|
|
|
|
StatusFilter: status,
|
|
|
|
|
Page: page,
|
|
|
|
|
PerPage: perPage,
|
|
|
|
|
Total: res.Total,
|
|
|
|
|
Pager: newPager("/registry", "registry-table-wrap", params, page, perPage, res.Total),
|
|
|
|
|
PerPageOptions: perPageOptions,
|
2026-09-23 11:58:53 +03:00
|
|
|
}
|
2026-09-23 09:52:01 +03:00
|
|
|
data.ActiveNav = "registry"
|
2026-10-06 14:23:48 +03:00
|
|
|
if run > 0 {
|
|
|
|
|
data.AnalyticsURL = analyticsURL(run, query)
|
|
|
|
|
}
|
|
|
|
|
if err == nil {
|
|
|
|
|
data.Breakdown = s.registryBreakdown(r, query, params)
|
|
|
|
|
err = errors.Join(runsErr, subnetsErr)
|
|
|
|
|
}
|
2026-09-23 09:52:01 +03:00
|
|
|
data.Banner = bannerFor(err)
|
2026-10-01 11:35:24 +03:00
|
|
|
s.renderPage(w, r, "registry_page", data)
|
2026-09-23 09:52:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-06 14:23:48 +03:00
|
|
|
// parseRegistryQuery reads the filter of the registry page and of its chart
|
|
|
|
|
// requests (?q=&status=&run=&subnet=&direction=&protocol=); a value that is
|
|
|
|
|
// not valid is dropped.
|
|
|
|
|
func parseRegistryQuery(r *http.Request) registryQuery {
|
|
|
|
|
v := r.URL.Query()
|
|
|
|
|
q := parseSliceFilter(v)
|
|
|
|
|
q.Q, q.LastResult = strings.TrimSpace(v.Get("q")), v.Get("status")
|
|
|
|
|
if !containsStr(ipResults, q.LastResult) {
|
|
|
|
|
q.LastResult = ""
|
|
|
|
|
}
|
|
|
|
|
return q
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseSliceFilter reads the part of the filter that the registry and the
|
|
|
|
|
// analytics page share (?run=&subnet=&direction=&protocol=): the data slice
|
|
|
|
|
// of a run, a subnet, and the direction and protocol of the checks. A value
|
|
|
|
|
// that is not valid is dropped.
|
|
|
|
|
func parseSliceFilter(v url.Values) registryQuery {
|
|
|
|
|
var q registryQuery
|
|
|
|
|
if q.Run, _ = strconv.ParseInt(v.Get("run"), 10, 64); q.Run < 0 {
|
|
|
|
|
q.Run = 0
|
|
|
|
|
}
|
|
|
|
|
if q.Subnet = strings.TrimSpace(v.Get("subnet")); q.Subnet != "" {
|
|
|
|
|
if _, err := netip.ParsePrefix(q.Subnet); err != nil {
|
|
|
|
|
q.Subnet = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if q.Direction = v.Get("direction"); !containsStr(registryDirections, q.Direction) {
|
|
|
|
|
q.Direction = ""
|
|
|
|
|
}
|
|
|
|
|
if q.Protocol = v.Get("protocol"); !containsStr(registryProtocols, q.Protocol) {
|
|
|
|
|
q.Protocol = ""
|
|
|
|
|
}
|
|
|
|
|
return q
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// subnetOption is one entry of the "Подсеть" selector.
|
|
|
|
|
type subnetOption struct {
|
|
|
|
|
CIDR, Text string
|
|
|
|
|
Selected bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registrySubnetOptions lists the configured subnets for the selector as
|
|
|
|
|
// "CIDR — label". The chosen subnet is always present and selected: one that is
|
|
|
|
|
// not configured (a link from analytics, or the list is unavailable) is added
|
|
|
|
|
// as a separate entry.
|
|
|
|
|
func registrySubnetOptions(subnets []subnetEntry, chosen string) []subnetOption {
|
|
|
|
|
var out []subnetOption
|
|
|
|
|
found := false
|
|
|
|
|
for _, x := range subnets {
|
|
|
|
|
text := x.CIDR
|
|
|
|
|
if x.Label != "" {
|
|
|
|
|
text += " — " + x.Label
|
|
|
|
|
}
|
|
|
|
|
out = append(out, subnetOption{CIDR: x.CIDR, Text: text, Selected: x.CIDR == chosen})
|
|
|
|
|
found = found || x.CIDR == chosen
|
|
|
|
|
}
|
|
|
|
|
if chosen != "" && !found {
|
|
|
|
|
out = append(out, subnetOption{CIDR: chosen, Text: chosen + " (нет в списке)", Selected: true})
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// breakdownView is the chart "successful checks per target / site" above the
|
|
|
|
|
// registry table. With Hint set, it is only a prompt to choose the missing
|
|
|
|
|
// filter; with Err set, the chart could not be loaded.
|
|
|
|
|
type breakdownView struct {
|
|
|
|
|
Title, Scope, Slice, QS string
|
|
|
|
|
Hint, Err string
|
|
|
|
|
Addresses int
|
|
|
|
|
Rows []breakdownRowView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type breakdownRowView struct {
|
|
|
|
|
Key, Label, Width, Text, Tip string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registryBreakdown builds the chart for the page's filter: nothing without a
|
|
|
|
|
// direction and a protocol, then a hint for the missing one. params is the
|
|
|
|
|
// filter as the page's links carry it; the chart's dialog requests its lists
|
|
|
|
|
// with the same query string.
|
|
|
|
|
func (s *Server) registryBreakdown(r *http.Request, q registryQuery, params url.Values) *breakdownView {
|
|
|
|
|
switch {
|
|
|
|
|
case q.Direction == "" && q.Protocol == "":
|
|
|
|
|
return nil
|
|
|
|
|
case q.Protocol == "":
|
|
|
|
|
return &breakdownView{Hint: "Выберите протокол, чтобы увидеть распределение по " + breakdownGroupName(q.Direction) + "."}
|
|
|
|
|
case q.Direction == "":
|
|
|
|
|
return &breakdownView{Hint: "Выберите направление, чтобы увидеть распределение по целям или площадкам."}
|
|
|
|
|
}
|
|
|
|
|
v := &breakdownView{Scope: strings.ToUpper(q.Direction[:1]) + q.Direction[1:] + " " + q.Protocol, QS: params.Encode(), Slice: "последний цикл адреса"}
|
|
|
|
|
if q.Run > 0 {
|
|
|
|
|
v.Slice = "запуск " + strconv.FormatInt(q.Run, 10)
|
|
|
|
|
}
|
|
|
|
|
if q.Subnet != "" {
|
|
|
|
|
v.Slice += " · подсеть " + q.Subnet
|
|
|
|
|
}
|
|
|
|
|
v.Title = "Успешные проверки по " + breakdownGroupName(q.Direction)
|
|
|
|
|
b, err := s.CA.GetRegistryBreakdown(r.Context(), q)
|
|
|
|
|
if err != nil {
|
|
|
|
|
v.Err = "Не удалось получить распределение: " + err.Error()
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
v.Addresses = b.Addresses
|
|
|
|
|
most := 0
|
|
|
|
|
for _, x := range b.Rows {
|
|
|
|
|
most = max(most, x.OK)
|
|
|
|
|
}
|
|
|
|
|
for _, x := range b.Rows { // control-api sorts them, most successful first
|
|
|
|
|
width := 0.0
|
|
|
|
|
if most > 0 {
|
|
|
|
|
width = float64(x.OK) * 100 / float64(most)
|
|
|
|
|
}
|
|
|
|
|
ok, total := groupThousands(x.OK), groupThousands(x.Total)
|
|
|
|
|
v.Rows = append(v.Rows, breakdownRowView{
|
|
|
|
|
Key: x.Key, Label: x.Label, Width: strconv.FormatFloat(width, 'f', 1, 64),
|
|
|
|
|
Text: ok + " из " + total + " · " + pct1(x.OK, x.Total) + "%",
|
|
|
|
|
Tip: x.Label + ": успешно " + ok + " из " + total + " проверок. Нажмите, чтобы открыть список адресов",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// breakdownGroupName is what the chart groups the checks of a direction by.
|
|
|
|
|
func breakdownGroupName(direction string) string {
|
|
|
|
|
if direction == "ingress" {
|
|
|
|
|
return "площадкам"
|
|
|
|
|
}
|
|
|
|
|
return "целям"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pct1 is a/b in percent with at most one decimal and a decimal comma: 98,7.
|
|
|
|
|
func pct1(a, b int) string {
|
|
|
|
|
if b == 0 {
|
|
|
|
|
return "0"
|
|
|
|
|
}
|
|
|
|
|
s := strconv.FormatFloat(float64(a)*100/float64(b), 'f', 1, 64)
|
|
|
|
|
return strings.Replace(strings.TrimSuffix(s, ".0"), ".", ",", 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// breakdownQuery reads the request of the chart's list proxies: the page's
|
|
|
|
|
// filter, with direction and protocol and the row's key required.
|
|
|
|
|
func breakdownQuery(w http.ResponseWriter, r *http.Request) (q registryQuery, key string, ok bool) {
|
|
|
|
|
q, key = parseRegistryQuery(r), r.URL.Query().Get("key")
|
|
|
|
|
if q.Direction == "" || q.Protocol == "" || key == "" {
|
|
|
|
|
http.Error(w, "direction, protocol and key are required", http.StatusBadRequest)
|
|
|
|
|
return q, key, false
|
|
|
|
|
}
|
|
|
|
|
return q, key, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleRegistryBreakdownList proxies the checks behind one row of the chart as JSON.
|
|
|
|
|
func (s *Server) handleRegistryBreakdownList(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
q, key, ok := breakdownQuery(w, r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
out, err := s.CA.GetRegistryBreakdownList(r.Context(), q, key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeProxyError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
_, _ = w.Write(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleRegistryBreakdownCSV proxies the same table as a CSV download.
|
|
|
|
|
func (s *Server) handleRegistryBreakdownCSV(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
q, key, ok := breakdownQuery(w, r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
body, disposition, err := s.CA.GetRegistryBreakdownCSV(r.Context(), q, key)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registryRunOptions lists the runs for the "Запуск" selector, newest first as
|
|
|
|
|
// control-api returns them; a finished run without addresses is hidden. The
|
|
|
|
|
// chosen run is always present and selected, even if the list lacks it (the
|
|
|
|
|
// history was cleaned up, or the list is unavailable).
|
|
|
|
|
func registryRunOptions(runs []analyticsRun, chosen int64) []runOption {
|
|
|
|
|
var out []runOption
|
|
|
|
|
found := false
|
|
|
|
|
for _, x := range runs {
|
|
|
|
|
if x.State == "finalized" && x.Addresses == 0 && x.ID != chosen {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
out = append(out, runOption{ID: x.ID, Label: runLabel(x), Selected: x.ID == chosen})
|
|
|
|
|
found = found || x.ID == chosen
|
|
|
|
|
}
|
|
|
|
|
if chosen > 0 && !found {
|
|
|
|
|
out = append(out, runOption{ID: chosen, Label: "Запуск " + strconv.FormatInt(chosen, 10), Selected: true})
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 09:52:01 +03:00
|
|
|
// handleRegistryDetail shows one address's full retained check history
|
|
|
|
|
// across every cycle it has ever run, not just the current attempt — see
|
|
|
|
|
// ip_detail_content in ip_detail.html for the attempt-scoped equivalent.
|
|
|
|
|
func (s *Server) handleRegistryDetail(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ip := r.PathValue("ip")
|
|
|
|
|
history, err := s.CA.GetRegistryHistory(r.Context(), ip)
|
|
|
|
|
data := registryDetailData{History: history}
|
|
|
|
|
data.ActiveNav = "registry"
|
|
|
|
|
data.Banner = bannerFor(err)
|
2026-10-01 11:35:24 +03:00
|
|
|
s.renderPage(w, r, "registry_detail_page", data)
|
2026-09-23 09:52:01 +03:00
|
|
|
}
|