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>
This commit is contained in:
1 parent
068c10ea1c
commit
ded196ec8d
40 files changed
+2545
-188
No files matched your search
@@ -244,8 +244,10 @@ func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
|
||||
type registryQuery struct {
|
||||
Q string
|
||||
LastResult string
|
||||
Run int64 // only addresses with a result in this run
|
||||
Run int64 // only addresses with a result in this run; the results shown are the run's
|
||||
Subnet string // only addresses inside this CIDR
|
||||
Direction string // egress|ingress — only checks of this direction
|
||||
Protocol string // icmp|tcp|ssh|https|tls — only checks of this protocol
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -255,11 +257,20 @@ type registryQuery struct {
|
||||
// history — survives an address being deleted from the queue and later
|
||||
// re-added) plus the total number of rows matching the filter.
|
||||
func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) {
|
||||
v := url.Values{}
|
||||
v := q.filter()
|
||||
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
|
||||
if q.Offset > 0 {
|
||||
v.Set("offset", strconv.Itoa(q.Offset))
|
||||
}
|
||||
var out registryPage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// filter is the filter part of the query (everything but the page) as the
|
||||
// parameters GET /admin/registry and its breakdown endpoints take.
|
||||
func (q registryQuery) filter() url.Values {
|
||||
v := url.Values{}
|
||||
if q.Q != "" {
|
||||
v.Set("q", q.Q)
|
||||
}
|
||||
@@ -272,11 +283,52 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr
|
||||
if q.Subnet != "" {
|
||||
v.Set("subnet", q.Subnet)
|
||||
}
|
||||
var out registryPage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
|
||||
if q.Direction != "" {
|
||||
v.Set("direction", q.Direction)
|
||||
}
|
||||
if q.Protocol != "" {
|
||||
v.Set("protocol", q.Protocol)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// registryBreakdownPath is the path of GET /admin/registry/breakdown, or of its
|
||||
// list of one row (key) when list is set, as a CSV file when csv is.
|
||||
func registryBreakdownPath(q registryQuery, key string, list, csv bool) string {
|
||||
v := q.filter()
|
||||
p := "/api/v1/admin/registry/breakdown"
|
||||
if list {
|
||||
p += "/list"
|
||||
v.Set("key", key)
|
||||
if csv {
|
||||
v.Set("format", "csv")
|
||||
}
|
||||
}
|
||||
return p + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// GetRegistryBreakdown returns the checks of the query's direction and protocol
|
||||
// per target or site, over the addresses the query selects.
|
||||
func (c *client) GetRegistryBreakdown(ctx context.Context, q registryQuery) (registryBreakdown, error) {
|
||||
var out registryBreakdown
|
||||
err := c.do(ctx, http.MethodGet, registryBreakdownPath(q, "", false, false), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetRegistryBreakdownList returns the table (JSON) of the checks behind one
|
||||
// row of the breakdown.
|
||||
func (c *client) GetRegistryBreakdownList(ctx context.Context, q registryQuery, key string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, registryBreakdownPath(q, key, true, false), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetRegistryBreakdownCSV returns the CSV file of that table, with the file
|
||||
// name control-api proposed.
|
||||
func (c *client) GetRegistryBreakdownCSV(ctx context.Context, q registryQuery, key string) ([]byte, string, error) {
|
||||
return c.getCSV(ctx, registryBreakdownPath(q, key, true, true))
|
||||
}
|
||||
|
||||
// GetRegistryHistory returns one address's registry record plus its full
|
||||
// retained check history across every cycle still kept.
|
||||
func (c *client) GetRegistryHistory(ctx context.Context, ip string) (registryHistoryResponse, error) {
|
||||
@@ -423,19 +475,27 @@ func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsReport returns the analytics of one finished run as the raw
|
||||
// JSON control-api computed; the page's script reads it as it is.
|
||||
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) {
|
||||
// GetAnalyticsReport returns the analytics of one finished run (of the
|
||||
// addresses inside subnet, unless it is empty) as the raw JSON control-api
|
||||
// computed; the page's script reads it as it is.
|
||||
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64, subnet string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out)
|
||||
p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10)
|
||||
if subnet != "" {
|
||||
p += "?" + url.Values{"subnet": {subnet}}.Encode()
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, p, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func analyticsListPath(runID int64, kind, class string, csv bool) string {
|
||||
func analyticsListPath(runID int64, kind, class, subnet string, csv bool) string {
|
||||
v := url.Values{}
|
||||
if class != "" {
|
||||
v.Set("class", class)
|
||||
}
|
||||
if subnet != "" {
|
||||
v.Set("subnet", subnet)
|
||||
}
|
||||
if csv {
|
||||
v.Set("format", "csv")
|
||||
}
|
||||
@@ -446,17 +506,18 @@ func analyticsListPath(runID int64, kind, class string, csv bool) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// GetAnalyticsList returns one address table (JSON) of a run.
|
||||
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) {
|
||||
// GetAnalyticsList returns one address table (JSON) of a run, narrowed to
|
||||
// subnet unless it is empty.
|
||||
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class, subnet string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out)
|
||||
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, subnet, false), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsListCSV returns the CSV file of one address table, with the
|
||||
// file name control-api proposed.
|
||||
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
|
||||
return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
|
||||
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class, subnet string) ([]byte, string, error) {
|
||||
return c.getCSV(ctx, analyticsListPath(runID, kind, class, subnet, true))
|
||||
}
|
||||
|
||||
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {
|
||||
|
||||
@@ -60,6 +60,13 @@ type fakeControlAPI struct {
|
||||
scanFreeAddresses []string
|
||||
registry map[string]registryItem
|
||||
registryChecks map[string][]check
|
||||
// The chart of /registry: the breakdown served (breakdownStatus != 0 makes
|
||||
// it fail with that status), the table behind a row and the raw queries
|
||||
// of both endpoints.
|
||||
breakdown registryBreakdown
|
||||
breakdownStatus int
|
||||
breakdownList string
|
||||
breakdownReqs []string
|
||||
|
||||
// Scan job state machine (see the scan handlers): POST starts a job that
|
||||
// stays "running" for scanRunPolls GET polls (0 = finishes at once), then
|
||||
@@ -483,6 +490,29 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/registry/breakdown", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.breakdownReqs = append(f.breakdownReqs, r.URL.RequestURI())
|
||||
if f.breakdownStatus != 0 {
|
||||
writeAPIErr(w, f.breakdownStatus, "breakdown unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, f.breakdown)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/registry/breakdown/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.breakdownReqs = append(f.breakdownReqs, r.URL.RequestURI())
|
||||
if r.URL.Query().Get("format") == "csv" {
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="registry_ingress_tls_rxmsk.csv"`)
|
||||
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(f.breakdownList))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -142,6 +142,25 @@ type registryPage struct {
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Run int64 `json:"run"` // the run the results are read from, 0 = newest cycles
|
||||
}
|
||||
|
||||
// registryBreakdown is GET /admin/registry/breakdown: the checks of one
|
||||
// direction and protocol per target (Group "target") or site (Group "site").
|
||||
type registryBreakdown struct {
|
||||
Group string `json:"group"`
|
||||
Direction string `json:"direction"`
|
||||
Protocol string `json:"protocol"`
|
||||
Run int64 `json:"run"`
|
||||
Addresses int `json:"addresses"`
|
||||
Rows []breakdownRow `json:"rows"`
|
||||
}
|
||||
|
||||
type breakdownRow struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
}
|
||||
|
||||
// scanStatusDTO is the state of control-api's background floating-IP scan job
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -24,6 +25,17 @@ type analyticsPageData struct {
|
||||
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
|
||||
@@ -40,12 +52,25 @@ type analyticsMeta struct {
|
||||
Rechecked int `json:"rechecked"`
|
||||
ListURL string `json:"list_url"`
|
||||
CSVURL string `json:"csv_url"`
|
||||
Registry string `json:"registry_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.
|
||||
// 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"
|
||||
@@ -56,7 +81,16 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
want, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
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 {
|
||||
@@ -94,15 +128,16 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
data.PrevURL = analyticsURL(finished[i+1].ID, f)
|
||||
}
|
||||
if i > 0 {
|
||||
data.NextURL = "/analytics?run=" + strconv.FormatInt(finished[i-1].ID, 10)
|
||||
data.NextURL = analyticsURL(finished[i-1].ID, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
data.CompareURL = compareURL(chosen.ID, f)
|
||||
|
||||
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID)
|
||||
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)
|
||||
@@ -120,7 +155,12 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
Start: fmtShort(chosen.StartedAt), Duration: "—", Rechecked: info.Run.Rechecked,
|
||||
ListURL: "/analytics/lists/",
|
||||
CSVURL: "/analytics/csv/",
|
||||
Registry: "/registry?run=" + id,
|
||||
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)
|
||||
@@ -137,14 +177,33 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
@@ -152,7 +211,7 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
|
||||
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"))
|
||||
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
|
||||
@@ -169,7 +228,7 @@ func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
|
||||
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"))
|
||||
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
|
||||
|
||||
@@ -80,6 +80,72 @@ func TestAnalyticsPageShowsOneRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The analytics filters: the subnet goes to control-api with the report and the
|
||||
// lists, direction and protocol focus the page and, both given, add the chart of
|
||||
// the registry for the run and subnet; every link of the page keeps the filter.
|
||||
func TestAnalyticsPageFilters(t *testing.T) {
|
||||
fake, ts := analyticsFake(t)
|
||||
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}}
|
||||
fake.breakdown = registryBreakdown{Group: "target", Direction: "egress", Protocol: "https", Addresses: 5, Rows: []breakdownRow{{Key: "a.test", Label: "a.test", Total: 5, OK: 4}}}
|
||||
lastReq := func(reqs []string) string { fake.mu.Lock(); defer fake.mu.Unlock(); return reqs[len(reqs)-1] }
|
||||
|
||||
page := get(t, ts, "/analytics?run=1&subnet=9.9.9.0/24&direction=egress&protocol=https")
|
||||
for _, want := range []string{
|
||||
`<option value="9.9.9.0/24" selected>9.9.9.0/24 — Офис</option>`, `<option value="egress" selected>`, `<option value="https" selected>`,
|
||||
`"subnet":"9.9.9.0/24"`, `"direction":"egress"`, `"protocol":"https"`, "сбросить фильтры",
|
||||
`id="registry-breakdown"`, "Успешные проверки по целям · Egress https", `data-slice="запуск 1 · подсеть 9.9.9.0/24"`,
|
||||
`data-qs="direction=egress&protocol=https&run=1&subnet=9.9.9.0%2F24"`,
|
||||
// the newer run is one step forward, the comparison is opened for this run; both keep the filter
|
||||
`href="/analytics?direction=egress&protocol=https&run=2&subnet=9.9.9.0%2F24"`,
|
||||
`href="/analytics/compare?direction=egress&protocol=https&subnet=9.9.9.0%2F24&target=1"`,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in the page, got:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if got := lastReq(fake.analyticsReqs); !strings.HasSuffix(got, "/runs/1?subnet=9.9.9.0%2F24") {
|
||||
t.Errorf("the report must be requested for the subnet, got %q", got)
|
||||
}
|
||||
req := lastReq(fake.breakdownReqs)
|
||||
for _, want := range []string{"run=1", "subnet=9.9.9.0%2F24", "direction=egress", "protocol=https"} {
|
||||
if !strings.Contains(req, want) {
|
||||
t.Errorf("chart request %q lacks %s", req, want)
|
||||
}
|
||||
}
|
||||
|
||||
// No chart without both direction and protocol (and no request for it); a
|
||||
// malformed subnet is dropped and the whole run is shown.
|
||||
fake.mu.Lock()
|
||||
fake.breakdownReqs = nil
|
||||
fake.mu.Unlock()
|
||||
for _, q := range []string{"run=1", "run=1&direction=ingress", "run=1&protocol=tls&subnet=garbage"} {
|
||||
page = get(t, ts, "/analytics?"+q)
|
||||
if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") {
|
||||
t.Errorf("%s: no chart and no hint expected:\n%s", q, page)
|
||||
}
|
||||
}
|
||||
if len(fake.breakdownReqs) != 0 {
|
||||
t.Errorf("the chart was requested without direction and protocol: %v", fake.breakdownReqs)
|
||||
}
|
||||
if got := lastReq(fake.analyticsReqs); strings.Contains(got, "subnet") {
|
||||
t.Errorf("a malformed subnet must be dropped, got %q", got)
|
||||
}
|
||||
|
||||
// The lists keep the subnet, a malformed one is passed on for control-api to refuse.
|
||||
for _, path := range []string{"/analytics/lists/egress_https_any?run=1&subnet=9.9.9.0/24", "/analytics/csv/egress_https_any?run=1&subnet=9.9.9.0/24"} {
|
||||
if page = get(t, ts, path); page == "" {
|
||||
t.Fatalf("%s: empty answer", path)
|
||||
}
|
||||
if got := lastReq(fake.analyticsReqs); !strings.Contains(got, "subnet=9.9.9.0%2F24") {
|
||||
t.Errorf("%s: control-api request %q lacks the subnet", path, got)
|
||||
}
|
||||
}
|
||||
get(t, ts, "/analytics/lists/egress_https_any?run=1&subnet=bad")
|
||||
if got := lastReq(fake.analyticsReqs); !strings.Contains(got, "subnet=bad") {
|
||||
t.Errorf("a malformed subnet must reach control-api, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
@@ -389,7 +455,7 @@ func TestSettingsSubnetsForm(t *testing.T) {
|
||||
}
|
||||
|
||||
// The drill-down from the analytics page: run and subnet go to control-api,
|
||||
// come back as hidden fields and a visible chip; a malformed subnet is dropped.
|
||||
// come back in the filter fields and the reset link; a malformed subnet is dropped.
|
||||
func TestRegistryDrillDownFromAnalytics(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
@@ -402,7 +468,8 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
|
||||
if !strings.Contains(last, "run=2") || !strings.Contains(last, "subnet=10.0.0.0%2F24") {
|
||||
t.Fatalf("control-api request %q lacks the run or subnet", last)
|
||||
}
|
||||
for _, want := range []string{`name="run" value="2"`, `name="subnet" value="10.0.0.0/24"`, "запуск 2", "подсеть 10.0.0.0/24", "сбросить фильтр"} {
|
||||
for _, want := range []string{`<option value="2" selected>`, `<option value="10.0.0.0/24" selected>10.0.0.0/24 (нет в списке)</option>`,
|
||||
`href="/analytics?run=2&subnet=10.0.0.0%2F24"`, "сбросить фильтры"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in:\n%s", want, page)
|
||||
}
|
||||
@@ -410,7 +477,7 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
|
||||
|
||||
page = get(t, ts, "/registry?subnet=garbage")
|
||||
last = fake.registryQueries[len(fake.registryQueries)-1]
|
||||
if strings.Contains(last, "subnet=") || strings.Contains(page, "сбросить фильтр") {
|
||||
if strings.Contains(last, "subnet=") || strings.Contains(page, `garbage`) {
|
||||
t.Fatalf("a malformed subnet must be ignored: %q", last)
|
||||
}
|
||||
}
|
||||
@@ -426,3 +493,122 @@ func TestAnalyticsCompareListPath(t *testing.T) {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The "Подсеть" selector: configured subnets as "CIDR — label", disabled with a
|
||||
// link to /settings when there are none.
|
||||
func TestRegistrySubnetSelect(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
page := get(t, ts, "/registry")
|
||||
for _, want := range []string{`<select id="registry-subnet" name="subnet" disabled`, `<option value="">Все подсети</option>`, `href="/settings">добавить в настройках`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("no subnets configured: expected %q in:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
|
||||
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}}
|
||||
page = get(t, ts, "/registry?subnet=10.0.0.0/8")
|
||||
for _, want := range []string{`<option value="9.9.9.0/24" >9.9.9.0/24 — Офис</option>`, `<option value="10.0.0.0/8" selected>10.0.0.0/8</option>`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "disabled") || strings.Contains(page, "нет в списке") {
|
||||
t.Fatalf("a configured subnet must neither disable the selector nor be added again:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
// The chart above the registry table: only with a direction and a protocol (a
|
||||
// hint for the missing one), rows as control-api sorted them, an error inside
|
||||
// the block that leaves the table in place; the list proxies forward the filter.
|
||||
func TestRegistryBreakdownChartAndProxy(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
now := time.Now()
|
||||
fake.registry["9.9.9.9"] = registryItem{IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now}
|
||||
fake.breakdown = registryBreakdown{Group: "site", Direction: "ingress", Protocol: "tls", Addresses: 1, Rows: []breakdownRow{
|
||||
{Key: "inbound-site-2", Label: "rxspb", Total: 1250, OK: 1234}, {Key: "inbound-site-1", Label: "rxmsk", Total: 617, OK: 617},
|
||||
}}
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
page := get(t, ts, "/registry")
|
||||
if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") {
|
||||
t.Fatalf("no chart and no hint without filters:\n%s", page)
|
||||
}
|
||||
page = get(t, ts, "/registry?direction=ingress")
|
||||
if !strings.Contains(page, "Выберите протокол, чтобы увидеть распределение по площадкам") || strings.Contains(page, `id="registry-breakdown"`) {
|
||||
t.Fatalf("a direction alone gives a hint:\n%s", page)
|
||||
}
|
||||
|
||||
page = get(t, ts, "/registry?direction=ingress&protocol=tls&run=3&subnet=9.9.9.0/24")
|
||||
for _, want := range []string{
|
||||
"Успешные проверки по площадкам · Ingress tls", `data-bd-key="inbound-site-2"`, `data-bd-label="rxspb"`,
|
||||
"1\u00a0234 из 1\u00a0250 · 98,7%", "617 из 617 · 100%", `style="width:100.0%"`, `style="width:50.0%"`,
|
||||
`data-slice="запуск 3 · подсеть 9.9.9.0/24"`, `data-qs="direction=ingress&protocol=tls&run=3&subnet=9.9.9.0%2F24"`, "Адресов под фильтром: 1",
|
||||
"9.9.9.9", // the table is still there
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Index(page, "rxspb") > strings.Index(page, "rxmsk") {
|
||||
t.Fatalf("rows must keep the order control-api gave:\n%s", page)
|
||||
}
|
||||
fake.mu.Lock()
|
||||
req := fake.breakdownReqs[len(fake.breakdownReqs)-1]
|
||||
fake.mu.Unlock()
|
||||
for _, want := range []string{"direction=ingress", "protocol=tls", "run=3", "subnet=9.9.9.0%2F24"} {
|
||||
if !strings.Contains(req, want) {
|
||||
t.Fatalf("breakdown request %q lacks %s", req, want)
|
||||
}
|
||||
}
|
||||
|
||||
fake.breakdown.Rows = nil
|
||||
if page = get(t, ts, "/registry?direction=egress&protocol=tls"); !strings.Contains(page, "Для этого сочетания проверок нет") {
|
||||
t.Fatalf("an empty chart says so:\n%s", page)
|
||||
}
|
||||
fake.breakdownStatus = http.StatusInternalServerError
|
||||
page = get(t, ts, "/registry?direction=egress&protocol=https")
|
||||
if !strings.Contains(page, "Не удалось получить распределение") || !strings.Contains(page, "9.9.9.9") {
|
||||
t.Fatalf("a chart error is shown in its block and the table stays:\n%s", page)
|
||||
}
|
||||
|
||||
// The list proxies: filter and key reach control-api; the filter is checked.
|
||||
fake.breakdownList = `{"columns":["Адрес"],"rows":[["1.2.3.4"]]}`
|
||||
for path, want := range map[string]int{
|
||||
"/registry/breakdown/list?direction=ingress&protocol=tls&key=inbound-site-1&run=3&status=fail": http.StatusOK,
|
||||
"/registry/breakdown/csv?direction=ingress&protocol=tls&key=inbound-site-1": http.StatusOK,
|
||||
"/registry/breakdown/list?direction=ingress&key=inbound-site-1": http.StatusBadRequest, // no protocol
|
||||
"/registry/breakdown/csv?direction=ingress&protocol=tls": http.StatusBadRequest, // no key
|
||||
} {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
|
||||
}
|
||||
if strings.Contains(path, "/csv") && want == http.StatusOK &&
|
||||
(!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || !strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_tls_rxmsk.csv")) {
|
||||
t.Fatalf("csv: %v %s", resp.Header, body)
|
||||
}
|
||||
if strings.Contains(path, "/list") && want == http.StatusOK && !strings.Contains(string(body), `"1.2.3.4"`) {
|
||||
t.Fatalf("list: %s", body)
|
||||
}
|
||||
}
|
||||
fake.mu.Lock()
|
||||
var list string
|
||||
for _, r := range fake.breakdownReqs {
|
||||
if strings.Contains(r, "/breakdown/list") && !strings.Contains(r, "format=csv") {
|
||||
list = r
|
||||
}
|
||||
}
|
||||
fake.mu.Unlock()
|
||||
for _, want := range []string{"key=inbound-site-1", "run=3", "last_result=fail", "direction=ingress", "protocol=tls"} {
|
||||
if !strings.Contains(list, want) {
|
||||
t.Fatalf("list request %q lacks %s", list, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -8,13 +9,28 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"}
|
||||
)
|
||||
|
||||
type registryPageData struct {
|
||||
PageData
|
||||
Items []registryItem
|
||||
Query string
|
||||
StatusFilter string
|
||||
Run int64 // drill-down from the analytics page
|
||||
Run int64 // data slice: the address's cycle in this run (also the drill-down from analytics)
|
||||
Subnet string
|
||||
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
|
||||
Page, PerPage int
|
||||
Total int
|
||||
Pager pagerData
|
||||
@@ -31,25 +47,18 @@ type registryDetailData struct {
|
||||
// record that survives an address being deleted from /ips and later
|
||||
// re-added. See internal/db/migrations/0007_ip_registry.sql. Optional
|
||||
// ?q=&status= query params narrow the list by address substring and by
|
||||
// LastResult, and ?page=&per_page= select a page — all applied server-side
|
||||
// (control-api's ListRegistryPage), so only the visible rows are transferred.
|
||||
// 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.
|
||||
func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
status := r.URL.Query().Get("status")
|
||||
if !containsStr(ipResults, status) {
|
||||
status = ""
|
||||
}
|
||||
query := parseRegistryQuery(r)
|
||||
q, status, run, subnet, direction, protocol := query.Q, query.LastResult, query.Run, query.Subnet, query.Direction, query.Protocol
|
||||
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
||||
page := parsePage(r.URL.Query().Get("page"))
|
||||
run, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
if run < 0 {
|
||||
run = 0
|
||||
}
|
||||
subnet := strings.TrimSpace(r.URL.Query().Get("subnet"))
|
||||
if _, err := netip.ParsePrefix(subnet); err != nil {
|
||||
subnet = ""
|
||||
}
|
||||
query := registryQuery{Q: q, LastResult: status, Run: run, Subnet: subnet, Limit: perPage, Offset: (page - 1) * perPage}
|
||||
query.Limit, query.Offset = perPage, (page-1)*perPage
|
||||
|
||||
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
||||
if err == nil {
|
||||
@@ -72,9 +81,31 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
if subnet != "" {
|
||||
params.Set("subnet", subnet)
|
||||
}
|
||||
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())
|
||||
}
|
||||
data := registryPageData{
|
||||
Run: run,
|
||||
Subnet: subnet,
|
||||
Direction: direction,
|
||||
Protocol: protocol,
|
||||
Protocols: registryProtocols,
|
||||
Scoped: direction != "" || protocol != "",
|
||||
Runs: registryRunOptions(runs, run),
|
||||
SubnetOptions: registrySubnetOptions(subnets.Subnets, subnet),
|
||||
Items: res.Items,
|
||||
Query: q,
|
||||
StatusFilter: status,
|
||||
@@ -85,10 +116,223 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
PerPageOptions: perPageOptions,
|
||||
}
|
||||
data.ActiveNav = "registry"
|
||||
if run > 0 {
|
||||
data.AnalyticsURL = analyticsURL(run, query)
|
||||
}
|
||||
if err == nil {
|
||||
data.Breakdown = s.registryBreakdown(r, query, params)
|
||||
err = errors.Join(runsErr, subnetsErr)
|
||||
}
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "registry_page", data)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -632,6 +633,53 @@ func TestRegistryPageAndDetail(t *testing.T) {
|
||||
if !strings.Contains(notFound, "alert-warning") {
|
||||
t.Fatalf("expected client error banner for unknown registry address, got:\n%s", notFound)
|
||||
}
|
||||
|
||||
// The four filters: the run and subnet choices come from control-api, the
|
||||
// values reach it and the pager, unknown direction/protocol are dropped, and
|
||||
// with a direction/protocol only the levels that have checks are shown.
|
||||
fake.runs = []analyticsRun{fakeRun(2, "open", 120, 40), fakeRun(1, "finalized", 900, 300)}
|
||||
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}}}
|
||||
fake.registry["9.9.9.8"] = registryItem{
|
||||
IPAddress: "9.9.9.8", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass",
|
||||
LastCycleID: 1, Ingress: levelResult{Total: 1, OK: 1, ByType: []typeStat{{"tls", 1, 1}}},
|
||||
}
|
||||
for i := 0; i < 30; i++ { // a second page for the pager
|
||||
ip := fmt.Sprintf("9.9.9.%d", 100+i)
|
||||
fake.registry[ip] = registryItem{IPAddress: ip, FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass",
|
||||
LastCycleID: 1, Ingress: levelResult{Total: 1, OK: 1, ByType: []typeStat{{"tls", 1, 1}}}}
|
||||
}
|
||||
page = get(t, ts, "/registry?run=1&subnet=9.9.9.0/24&direction=ingress&protocol=tls&per_page=25")
|
||||
fake.mu.Lock()
|
||||
last := fake.registryQueries[len(fake.registryQueries)-1]
|
||||
fake.mu.Unlock()
|
||||
for _, want := range []string{"run=1", "subnet=9.9.9.0%2F24", "direction=ingress", "protocol=tls"} {
|
||||
if !strings.Contains(last, want) {
|
||||
t.Fatalf("control-api request %q lacks %s", last, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<option value="1" selected>`, `<option value="2" >`, `<option value="9.9.9.0/24" selected>9.9.9.0/24 — Офис</option>`,
|
||||
`value="ingress" selected`, `value="tls" selected`, "Результат в запуске 1", `level-name">Ingress`,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in the filtered registry page, got:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, `level-name">Egress`) {
|
||||
t.Fatalf("the Egress level must be hidden when only ingress checks are selected, got:\n%s", page)
|
||||
}
|
||||
next := pagerLink(t, page, "next")
|
||||
if next == nil || next.Query().Get("run") != "1" || next.Query().Get("subnet") != "9.9.9.0/24" ||
|
||||
next.Query().Get("direction") != "ingress" || next.Query().Get("protocol") != "tls" {
|
||||
t.Fatalf("pager link lost the filters: %v", next)
|
||||
}
|
||||
get(t, ts, "/registry?direction=sideways&protocol=udp")
|
||||
fake.mu.Lock()
|
||||
last = fake.registryQueries[len(fake.registryQueries)-1]
|
||||
fake.mu.Unlock()
|
||||
if strings.Contains(last, "direction=") || strings.Contains(last, "protocol=") {
|
||||
t.Fatalf("unknown direction/protocol must be dropped: %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryPageShowsEgressIngressLevels proves the list shows, under the
|
||||
|
||||
@@ -28,6 +28,8 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("GET /registry", s.handleRegistryPage)
|
||||
mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail)
|
||||
mux.HandleFunc("GET /registry/breakdown/list", s.handleRegistryBreakdownList)
|
||||
mux.HandleFunc("GET /registry/breakdown/csv", s.handleRegistryBreakdownCSV)
|
||||
|
||||
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
|
||||
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
.an select, .an .an-btn { font: 500 13px var(--font-mono); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--an-radius); padding: 7px 10px; }
|
||||
.an select { min-width: 0; max-width: 100%; flex: 1 1 160px; }
|
||||
.an-btn { cursor: pointer; } .an-btn:hover { background: var(--surface-alt); }
|
||||
.an [hidden] { display: none !important; }
|
||||
.an-body { display: grid; gap: 16px; min-width: 0; align-content: start; }
|
||||
#an-filter { display: grid; gap: 12px; }
|
||||
/* filter fields: label above the select, wrapping as whole pairs; in a column the fields keep their own height */
|
||||
.an-fields { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; }
|
||||
.an-field { display: flex; flex-direction: column; gap: 5px; flex: 1 1 160px; min-width: 0; }
|
||||
.an-field label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.an-field select { flex: 0 0 auto; }
|
||||
.an-reset { text-decoration: none; }
|
||||
.an .bd-wrap { margin-bottom: 0; } /* the chart of the registry brings its own space; here the grid gap is enough */
|
||||
.an-tabs { display: inline-flex; gap: 0; }
|
||||
.an-tabs button { font: 500 12px var(--font-mono); background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); padding: 5px 12px; cursor: pointer; }
|
||||
.an-tabs button + button { border-left: 0; }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* Analytics page: renders the report of one finished run (embedded as JSON in
|
||||
#analytics-data) and opens the address lists behind the indicators and the
|
||||
error classes in the shared dialog (analytics-dialog.js). No other run's data
|
||||
is on the page. */
|
||||
is on the page. The report is already narrowed to the chosen subnet; the
|
||||
direction and protocol only focus the page (blocks of the other level are
|
||||
hidden, the type tab and the sites column follow the protocol). */
|
||||
(function () {
|
||||
'use strict';
|
||||
var D = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
@@ -14,6 +16,7 @@
|
||||
function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
|
||||
|
||||
var state = { sort: 'worst', all: false, type: R.targets.types.indexOf('https') >= 0 ? 'https' : (R.targets.types[0] || '') };
|
||||
if (R.targets.types.indexOf(M.protocol) >= 0) state.type = M.protocol;
|
||||
|
||||
/* ---- indicators ---- */
|
||||
function renderKpis() {
|
||||
@@ -124,11 +127,12 @@
|
||||
}
|
||||
|
||||
function renderSites() {
|
||||
var T = R.sites;
|
||||
$('an-sites').innerHTML = '<thead><tr><th>Площадка</th>' + T.types.map(function (t) { return '<th class="an-r">' + esc(t) + '</th>'; }).join('') + '</tr></thead><tbody>' +
|
||||
var T = R.sites, only = T.types.indexOf(M.protocol); // with a protocol only its column stays
|
||||
function shown(x, i) { return only < 0 || i === only; }
|
||||
$('an-sites').innerHTML = '<thead><tr><th>Площадка</th>' + T.types.filter(shown).map(function (t) { return '<th class="an-r">' + esc(t) + '</th>'; }).join('') + '</tr></thead><tbody>' +
|
||||
T.rows.map(function (row) {
|
||||
return '<tr><td class="an-a">' + esc(row.site) + '</td>' + row.stats.map(function (st, i) {
|
||||
return '<td class="an-r" ' + tipAttr(row.site + ' · ' + T.types[i] + ': успешно ' + fmt(st.ok) + ' из ' + fmt(st.total)) + '><span class="an-num">' + pct1(st.total - st.ok, st.total) + '%</span> <span class="an-note">провал</span></td>';
|
||||
return shown(st, i) ? '<td class="an-r" ' + tipAttr(row.site + ' · ' + T.types[i] + ': успешно ' + fmt(st.ok) + ' из ' + fmt(st.total)) + '><span class="an-num">' + pct1(st.total - st.ok, st.total) + '%</span> <span class="an-note">провал</span></td>' : '';
|
||||
}).join('') + '</tr>';
|
||||
}).join('') + '</tbody>';
|
||||
}
|
||||
@@ -206,14 +210,14 @@
|
||||
};
|
||||
|
||||
function listURL(base, kind, cls) {
|
||||
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
|
||||
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '') + (M.subnet ? '&subnet=' + encodeURIComponent(M.subnet) : '');
|
||||
}
|
||||
|
||||
function loadList(kind, cls, meta) { return A.load(listURL(M.list_url, kind, cls), meta.title); }
|
||||
|
||||
function runLabel() {
|
||||
var o = document.getElementById('an-run');
|
||||
return o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id;
|
||||
return (o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id) + (M.subnet ? ' · подсеть ' + M.subnet : '');
|
||||
}
|
||||
|
||||
function openIndicator(kind) {
|
||||
@@ -250,6 +254,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- focus: direction and protocol ---- */
|
||||
function applyFocus() {
|
||||
$('an-sec-in').hidden = M.direction === 'egress';
|
||||
$('an-sec-eg').hidden = $('an-sec-val').hidden = M.direction === 'ingress';
|
||||
}
|
||||
|
||||
/* ---- wiring ---- */
|
||||
function renderAll() {
|
||||
renderSubnets();
|
||||
@@ -258,8 +268,15 @@
|
||||
}
|
||||
|
||||
$('an-runnote').textContent = 'Тип: ' + M.kind + '. Начало ' + M.start + ', завершён ' + M.end + ', длительность ' + M.duration + '.' +
|
||||
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '');
|
||||
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll();
|
||||
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '') +
|
||||
(R.scope ? ' Подсеть ' + R.scope.subnet + ': ' + fmt(S.addresses) + ' адр. из ' + fmt(R.scope.run_addresses) + ' в запуске.' : '');
|
||||
if (R.scope && !S.addresses) {
|
||||
$('an-empty').textContent = 'В запуске нет адресов этой подсети.';
|
||||
$('an-empty').hidden = false;
|
||||
$('an-body').hidden = true;
|
||||
return;
|
||||
}
|
||||
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll(); applyFocus();
|
||||
|
||||
$('an-kpis').addEventListener('click', function (e) { var b = e.target.closest('[data-list]'); if (b) openIndicator(b.dataset.list); });
|
||||
$('an-errs').addEventListener('click', function (e) { var b = e.target.closest('[data-cls]'); if (b) openError(b.dataset.cls); });
|
||||
|
||||
@@ -507,8 +507,16 @@ code.inline { font-family: var(--font-mono); background: var(--surface-alt); bor
|
||||
.main { padding: 16px 14px 50px; }
|
||||
}
|
||||
|
||||
.bd-wrap { margin-bottom: 16px; }
|
||||
/* Chart of the registry: the figures column has one width in every row, so the bars line up. */
|
||||
#registry-breakdown .an-bar-row { grid-template-columns: minmax(110px, 30%) minmax(0, 1fr) 15em; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-row { flex-direction: column; align-items: stretch; }
|
||||
/* the registry fields carry an inline flex basis (for rows); in a column it would become a height */
|
||||
#registry-filter .field { flex: 0 0 auto !important; }
|
||||
#registry-breakdown .an-bar-row { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
#registry-breakdown .an-bar-row .an-track { grid-column: 1 / -1; grid-row: 2; }
|
||||
thead { display: none; }
|
||||
table, tbody, tr, td { display: block; width: 100%; }
|
||||
tbody tr { border-bottom: 1px solid var(--border-soft); padding: 9px 16px; }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* The chart of the registry ("registry_breakdown" in registry.html): a click on
|
||||
a row opens the checks behind it in the analytics dialog (analytics-dialog.js).
|
||||
The listener sits on the document, so it keeps working after htmx replaces the
|
||||
table; the filter of the list is the one the chart was built with (data-qs). */
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.registryBreakdownBound) return;
|
||||
window.registryBreakdownBound = true;
|
||||
|
||||
var HINTS = {
|
||||
'Результат': 'Итог именно этой проверки. Список начинается с проваленных.',
|
||||
'Тип проверки': 'https, icmp, ssh, tcp-22, tls-443 и т. д.',
|
||||
'Валидатор': 'Валидатор, с которого шла egress-проверка.',
|
||||
'Задержка, мс': 'Сколько заняла проверка.',
|
||||
'Детали': 'Ошибка проверки, если она провалена.'
|
||||
};
|
||||
|
||||
function open(btn) {
|
||||
var A = window.AnalyticsDialog, box = btn.closest('#registry-breakdown');
|
||||
if (!A || !box) return;
|
||||
var tail = (box.dataset.qs ? box.dataset.qs + '&' : '') + 'key=' + encodeURIComponent(btn.dataset.bdKey);
|
||||
var title = box.dataset.scope + ' · ' + btn.dataset.bdLabel;
|
||||
A.load('/registry/breakdown/list?' + tail, title).then(function (l) {
|
||||
if (!l) return;
|
||||
var ok = l.rows.filter(function (r) { return r[1] === 'успешно'; }).length;
|
||||
var hints = {};
|
||||
l.columns.forEach(function (c, i) { if (HINTS[c]) hints[i] = HINTS[c]; });
|
||||
A.fill({
|
||||
title: title,
|
||||
scope: 'Срез',
|
||||
runLabel: box.dataset.slice,
|
||||
note: 'Проверки этого типа в цикле адреса среза, у адресов под текущим фильтром. Провалы сверху.',
|
||||
cols: l.columns, rows: l.rows, hints: hints,
|
||||
dist: '<div class="an-chips"><span class="an-tag an-t-ok">успешно ' + A.fmt(ok) + '</span><span class="an-tag an-t-bad">провал ' + A.fmt(l.rows.length - ok) + '</span></div>',
|
||||
csvURL: '/registry/breakdown/csv?' + tail
|
||||
});
|
||||
// the error text of a failed check is long: give its column room instead of a narrow wrapped strip
|
||||
var d = l.columns.indexOf('Детали');
|
||||
if (d >= 0) document.querySelectorAll('#an-dlg-tbl tr').forEach(function (tr) { if (tr.children[d]) tr.children[d].style.minWidth = '280px'; });
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('[data-bd-key]');
|
||||
if (b) open(b);
|
||||
});
|
||||
})();
|
||||
@@ -20,6 +20,7 @@
|
||||
{{if .HasRun}}
|
||||
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
|
||||
<script src="/static/analytics-dialog.js"></script>
|
||||
<script src="/static/registry.js"></script>
|
||||
<script src="/static/analytics.js"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
@@ -38,22 +39,62 @@
|
||||
<p class="an-note">Запусков проверки пока нет. Они появляются, когда адреса ставятся в очередь на странице <a href="/ips">«Очередь IP»</a> или запускается автоматический цикл.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="an-panel" aria-label="Выбор запуска">
|
||||
<section class="an-panel" aria-label="Выбор запуска и фильтры">
|
||||
<form id="an-filter" action="/analytics" method="get" onsubmit="return false">
|
||||
{{/* a change goes to the page of the new filter; empty values stay out of the address */}}
|
||||
<script>function anGo(f) { var q = new URLSearchParams(); new FormData(f).forEach(function (v, k) { if (v) q.set(k, v); }); location.href = '/analytics?' + q; }</script>
|
||||
<div class="an-runbar">
|
||||
<label for="an-run">Запуск</label>
|
||||
{{if .PrevURL}}<a class="an-btn" href="{{.PrevURL}}" aria-label="Предыдущий запуск">◀</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">◀</span>{{end}}
|
||||
<select id="an-run" onchange="if (this.value) location.href = '/analytics?run=' + encodeURIComponent(this.value)">
|
||||
<select id="an-run" name="run" onchange="if (this.value) anGo(this.form)">
|
||||
{{range .Runs}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}{{if .Disabled}} disabled{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{if .NextURL}}<a class="an-btn" href="{{.NextURL}}" aria-label="Следующий запуск">▶</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">▶</span>{{end}}
|
||||
{{if .HasRun}}<a class="an-btn" href="/analytics/compare?target={{.RunID}}">Сравнить с другим запуском</a>{{end}}
|
||||
{{if .HasRun}}<a class="an-btn" href="{{.CompareURL}}">Сравнить с другим запуском</a>{{end}}
|
||||
</div>
|
||||
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
|
||||
{{if .HasRun}}
|
||||
<div class="an-fields">
|
||||
<div class="an-field">
|
||||
<label for="an-subnet">Подсеть</label>
|
||||
<select id="an-subnet" name="subnet" onchange="anGo(this.form)"{{if not .SubnetOptions}} disabled{{end}}>
|
||||
<option value="">Все подсети</option>
|
||||
{{range .SubnetOptions}}<option value="{{.CIDR}}"{{if .Selected}} selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{if not .SubnetOptions}}<span class="an-note">Подсети не настроены — <a href="/settings">добавить в настройках</a></span>{{end}}
|
||||
</div>
|
||||
<div class="an-field">
|
||||
<label for="an-direction">Направление</label>
|
||||
<select id="an-direction" name="direction" onchange="anGo(this.form)">
|
||||
<option value="">Все</option>
|
||||
<option value="egress"{{if eq .Direction "egress"}} selected{{end}}>Egress</option>
|
||||
<option value="ingress"{{if eq .Direction "ingress"}} selected{{end}}>Ingress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="an-field">
|
||||
<label for="an-protocol">Протокол</label>
|
||||
<select id="an-protocol" name="protocol" onchange="anGo(this.form)">
|
||||
<option value="">Все</option>
|
||||
{{$pr := .Protocol}}
|
||||
{{range $p := .Protocols}}<option value="{{$p}}"{{if eq $p $pr}} selected{{end}}>{{$p}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{if .Filtered}}<a class="an-btn an-reset" href="/analytics?run={{.RunID}}">сбросить фильтры</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>
|
||||
<p class="an-note">Подсеть пересчитывает все блоки страницы по её адресам. Направление и протокол задают фокус: скрывают блоки другого уровня и выбирают тип проверки, а вердикты и «Качество данных» остаются по всем проверкам запуска. С направлением и протоколом сразу показывается чарт.{{if .Breakdown}} Чарт считает проверки цикла запуска, в том числе пришедшие после вердикта, а плитки ниже берут вердикты запуска, поэтому числа могут расходиться.{{end}}</p>
|
||||
{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasRun}}
|
||||
{{with .Breakdown}}{{template "registry_breakdown" .}}{{end}}
|
||||
<p class="an-note" id="an-empty" hidden></p>
|
||||
<div class="an-body" id="an-body">
|
||||
<div class="an-kpis" id="an-kpis"></div>
|
||||
|
||||
<div class="an-cols">
|
||||
@@ -84,7 +125,7 @@
|
||||
<p class="an-note">Строка ведёт в «Реестр» с фильтром по запуску и подсети.</p>
|
||||
</section>
|
||||
|
||||
<section class="an-panel" aria-labelledby="an-h-eg">
|
||||
<section class="an-panel" id="an-sec-eg" aria-labelledby="an-h-eg">
|
||||
<div class="an-head">
|
||||
<h2 id="an-h-eg">Egress по целям</h2>
|
||||
<div class="an-tabs" role="group" aria-label="Тип проверки" id="an-types"></div>
|
||||
@@ -98,7 +139,7 @@
|
||||
<p class="an-note" id="an-matrixnote"></p>
|
||||
</section>
|
||||
|
||||
<div class="an-cols">
|
||||
<div class="an-cols" id="an-sec-in">
|
||||
<section class="an-panel" aria-labelledby="an-h-in">
|
||||
<h2 id="an-h-in">Ingress по площадкам</h2>
|
||||
<div class="an-scroll"><table id="an-sites"></table></div>
|
||||
@@ -109,12 +150,13 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="an-panel" aria-labelledby="an-h-val">
|
||||
<section class="an-panel" id="an-sec-val" aria-labelledby="an-h-val">
|
||||
<h2 id="an-h-val">Валидаторы: доля провалов egress https</h2>
|
||||
<div class="an-vals" id="an-vals" role="img" aria-label="Доля проваленных https-проверок по валидаторам"></div>
|
||||
<div class="an-vlab"><span id="an-vfirst"></span><span id="an-vavg"></span><span id="an-vlast"></span></div>
|
||||
<p class="an-note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{template "analytics_dialog"}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{{define "registry_page"}}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<head>{{template "html_head" .}}
|
||||
<link rel="stylesheet" href="/static/analytics.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-grid"></div>
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
@@ -15,6 +17,10 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{{/* The list behind a row of the chart opens in the analytics dialog. */}}
|
||||
<div class="an">{{template "analytics_dialog"}}</div>
|
||||
<script src="/static/analytics-dialog.js"></script>
|
||||
<script src="/static/registry.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -26,13 +32,6 @@
|
||||
Глубина хранимой истории на адрес настраивается на <a href="/settings">странице настроек</a>.</p>
|
||||
|
||||
<form id="registry-filter" class="panel" onsubmit="return false" style="margin-bottom:16px">
|
||||
{{if .Run}}<input type="hidden" name="run" value="{{.Run}}">{{end}}
|
||||
{{if .Subnet}}<input type="hidden" name="subnet" value="{{.Subnet}}">{{end}}
|
||||
{{if or .Run .Subnet}}<div class="panel-body" style="padding-bottom:0">
|
||||
<span class="pill pill-info">Из аналитики:{{if .Run}} запуск {{.Run}}{{end}}{{if .Subnet}} · подсеть {{.Subnet}}{{end}}</span>
|
||||
<a href="/registry" style="margin-left:10px">сбросить фильтр</a>
|
||||
{{if .Run}}<a href="/analytics?run={{.Run}}" style="margin-left:10px">к аналитике</a>{{end}}
|
||||
</div>{{end}}
|
||||
<div class="panel-body field-row">
|
||||
<div class="field" style="flex:1 1 260px">
|
||||
<label for="registry-q">Поиск по IP</label>
|
||||
@@ -41,6 +40,52 @@
|
||||
hx-include="#registry-filter" hx-trigger="input changed delay:300ms"
|
||||
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
|
||||
</div>
|
||||
<div class="field" style="flex:1 1 300px">
|
||||
<label for="registry-run">Запуск</label>
|
||||
<select id="registry-run" name="run"
|
||||
hx-get="/registry" hx-select="#registry-table-wrap" hx-target="#registry-table-wrap" hx-swap="outerHTML"
|
||||
hx-include="#registry-filter" hx-trigger="change"
|
||||
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
|
||||
<option value="">Последний цикл (по умолчанию)</option>
|
||||
{{range .Runs}}<option value="{{.ID}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:1 1 200px">
|
||||
<label for="registry-subnet">Подсеть</label>
|
||||
<select id="registry-subnet" name="subnet" {{if not .SubnetOptions}}disabled{{end}}
|
||||
hx-get="/registry" hx-select="#registry-table-wrap" hx-target="#registry-table-wrap" hx-swap="outerHTML"
|
||||
hx-include="#registry-filter" hx-trigger="change"
|
||||
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
|
||||
<option value="">Все подсети</option>
|
||||
{{range .SubnetOptions}}<option value="{{.CIDR}}" {{if .Selected}}selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{if not .SubnetOptions}}<span class="muted" style="font-size:12px">Подсети не настроены — <a href="/settings">добавить в настройках</a></span>{{end}}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="registry-direction">Направление</label>
|
||||
<select id="registry-direction" name="direction"
|
||||
hx-get="/registry" hx-select="#registry-table-wrap" hx-target="#registry-table-wrap" hx-swap="outerHTML"
|
||||
hx-include="#registry-filter" hx-trigger="change"
|
||||
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
|
||||
<option value="">Все</option>
|
||||
<option value="egress" {{if eq .Direction "egress"}}selected{{end}}>Egress</option>
|
||||
<option value="ingress" {{if eq .Direction "ingress"}}selected{{end}}>Ingress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="registry-protocol">Протокол</label>
|
||||
<select id="registry-protocol" name="protocol"
|
||||
hx-get="/registry" hx-select="#registry-table-wrap" hx-target="#registry-table-wrap" hx-swap="outerHTML"
|
||||
hx-include="#registry-filter" hx-trigger="change"
|
||||
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
|
||||
<option value="">Все</option>
|
||||
{{$pr := .Protocol}}
|
||||
{{range $p := .Protocols}}<option value="{{$p}}" {{if eq $p $pr}}selected{{end}}>{{$p}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="registry-status">Статус</label>
|
||||
<select id="registry-status" name="status"
|
||||
@@ -66,6 +111,12 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body muted" style="padding-top:0;font-size:12.5px">
|
||||
Запуск задаёт срез: результат берётся по циклу адреса в этом запуске, а статус — по вердикту запуска.
|
||||
Направление и протокол оставляют только такие проверки, и статус тогда считается по ним, а не по общему вердикту.
|
||||
tls проверяется только на входе (Ingress).
|
||||
<a href="/registry" style="margin-left:10px">сбросить фильтры</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="registry-table-wrap">
|
||||
@@ -82,12 +133,34 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "registry_breakdown"}}
|
||||
{{if .Hint}}<p class="muted" style="margin-bottom:12px">{{.Hint}}</p>
|
||||
{{else}}
|
||||
<div class="an bd-wrap">
|
||||
<section class="an-panel" id="registry-breakdown" aria-labelledby="registry-breakdown-h" data-qs="{{.QS}}" data-scope="{{.Scope}}" data-slice="{{.Slice}}">
|
||||
<h2 id="registry-breakdown-h">{{.Title}} · {{.Scope}}</h2>
|
||||
{{if .Err}}<p class="an-note">{{.Err}}</p>
|
||||
{{else if not .Rows}}<p class="an-note">Для этого сочетания проверок нет.</p>
|
||||
{{else}}
|
||||
<div class="an-rows">
|
||||
{{range .Rows}}<button type="button" class="an-bar-row" data-bd-key="{{.Key}}" data-bd-label="{{.Label}}" aria-haspopup="dialog" data-tip="{{.Tip}}" aria-label="{{.Tip}}"><span class="an-n">{{.Label}}</span><div class="an-track"><div class="an-fill" style="width:{{.Width}}%"></div></div><span class="an-num">{{.Text}}</span></button>
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="an-note">Адресов под фильтром: {{.Addresses}}. Срез: {{.Slice}}. Строки идут от большего числа успешных проверок к меньшему. Считаются проверки, а не адреса: у tcp и tls на ingress у адреса может быть по проверке на каждую площадку и порт (22, 443). Строка открывает список адресов.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "registry_table"}}
|
||||
{{with .Breakdown}}{{template "registry_breakdown" .}}{{end}}
|
||||
{{if or .Items .Run}}<p class="muted" style="margin-bottom:8px">Найдено адресов: {{.Total}}{{if .Run}} · <a href="{{.AnalyticsURL}}">к аналитике запуска {{.Run}}</a>{{end}}</p>{{end}}
|
||||
{{if .Items}}
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Адрес</th><th>Впервые замечен</th><th>Последний раз замечен</th><th>Циклов</th><th>Последний результат</th><th>Сейчас в очереди</th></tr></thead>
|
||||
<thead><tr><th>Адрес</th><th>Впервые замечен</th><th>Последний раз замечен</th><th>Циклов</th><th>{{if .Run}}Результат в запуске {{.Run}}{{else}}Последний результат{{end}}</th><th>Сейчас в очереди</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
@@ -95,15 +168,15 @@
|
||||
<td data-label="Впервые замечен">{{fmtTime .FirstSeenAt}}</td>
|
||||
<td data-label="Последний раз замечен">{{fmtTime .LastSeenAt}}</td>
|
||||
<td class="num" data-label="Циклов">{{.TotalCycles}}</td>
|
||||
<td data-label="Последний результат">
|
||||
<td data-label="{{if $.Run}}Результат в запуске {{$.Run}}{{else}}Последний результат{{end}}">
|
||||
{{if eq .LastResult "pass"}}<span class="pill pill-success">pass</span>
|
||||
{{else if eq .LastResult "partial"}}<span class="pill pill-warning">partial</span>
|
||||
{{else if eq .LastResult "fail"}}<span class="pill pill-danger">fail</span>
|
||||
{{else if eq .LastResult "cancelled"}}<span class="pill pill-cancel">cancelled</span>
|
||||
{{else}}<span class="pill pill-neutral">—</span>{{end}}
|
||||
{{if .LastCycleID}}<div class="levels" title="Считаются записанные проверки цикла {{.LastCycleID}}; вердикт учитывает ещё и недостающие результаты.">
|
||||
{{template "registry_level" dict "Name" "Egress" "L" .Egress}}
|
||||
{{template "registry_level" dict "Name" "Ingress" "L" .Ingress}}
|
||||
{{if or (not $.Scoped) .Egress.Total}}{{template "registry_level" dict "Name" "Egress" "L" .Egress}}{{end}}
|
||||
{{if or (not $.Scoped) .Ingress.Total}}{{template "registry_level" dict "Name" "Ingress" "L" .Ingress}}{{end}}
|
||||
</div>{{end}}
|
||||
</td>
|
||||
<td data-label="Сейчас в очереди">
|
||||
@@ -116,6 +189,6 @@
|
||||
</div>
|
||||
{{template "pager" .Pager}}
|
||||
</div>
|
||||
{{else if or .Query .StatusFilter}}<p class="muted">Ничего не найдено по текущему фильтру.</p>
|
||||
{{else if or .Query .StatusFilter .Run .Subnet .Direction .Protocol}}<p class="muted">Ничего не найдено по текущему фильтру.</p>
|
||||
{{else}}<p class="muted">Реестр пуст — ни один адрес ещё не ставился на проверку.</p>{{end}}
|
||||
{{end}}
|
||||
Reference in new issue
Block a user