Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
864208238f
commit
b7669c9e41
44 files changed
+4123
-62
No files matched your search
@@ -244,6 +244,8 @@ 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
|
||||
Subnet string // only addresses inside this CIDR
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -264,6 +266,12 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr
|
||||
if q.LastResult != "" {
|
||||
v.Set("last_result", q.LastResult)
|
||||
}
|
||||
if q.Run > 0 {
|
||||
v.Set("run", strconv.FormatInt(q.Run, 10))
|
||||
}
|
||||
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)
|
||||
return out, err
|
||||
@@ -392,3 +400,104 @@ func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// analyticsRun is one entry of GET /admin/analytics/runs (the run selector).
|
||||
type analyticsRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at"`
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
Partial int `json:"partial"`
|
||||
Fail int `json:"fail"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error) {
|
||||
var out []analyticsRun
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs", nil, &out)
|
||||
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) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func analyticsListPath(runID int64, kind, class string, csv bool) string {
|
||||
v := url.Values{}
|
||||
if class != "" {
|
||||
v.Set("class", class)
|
||||
}
|
||||
if csv {
|
||||
v.Set("format", "csv")
|
||||
}
|
||||
p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10) + "/lists/" + url.PathEscape(kind)
|
||||
if len(v) > 0 {
|
||||
p += "?" + v.Encode()
|
||||
}
|
||||
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) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, 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) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &apiErr{Status: 0, Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
msg := string(body)
|
||||
var er errorResponse
|
||||
if json.Unmarshal(body, &er) == nil && er.Error != "" {
|
||||
msg = er.Error
|
||||
}
|
||||
return nil, "", &apiErr{Status: resp.StatusCode, Message: msg}
|
||||
}
|
||||
return body, resp.Header.Get("Content-Disposition"), nil
|
||||
}
|
||||
|
||||
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
|
||||
type subnetEntry struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type subnetList struct {
|
||||
Subnets []subnetEntry `json:"subnets"`
|
||||
}
|
||||
|
||||
func (c *client) GetSubnets(ctx context.Context) (subnetList, error) {
|
||||
var out subnetList
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/subnets", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *client) PutSubnets(ctx context.Context, in subnetList) (subnetList, error) {
|
||||
var out subnetList
|
||||
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/subnets", in, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -37,6 +37,14 @@ type fakeControlAPI struct {
|
||||
inboundICMP bool
|
||||
|
||||
historyRetentionCycles int
|
||||
|
||||
// Analytics: the run selector, the report JSON per run, the lists per
|
||||
// "run/kind[/class]" and the subnet list of /settings.
|
||||
runs []analyticsRun
|
||||
reports map[int64]string
|
||||
lists map[string]string
|
||||
subnets subnetList
|
||||
analyticsReqs []string
|
||||
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
|
||||
// directly rather than this fake reimplementing OpenStack floating-IP
|
||||
// filtering (already covered by internal/orchestrator's own tests).
|
||||
@@ -469,6 +477,76 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.subnets
|
||||
if out.Subnets == nil {
|
||||
out.Subnets = []subnetEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in subnetList
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeAPIErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
for _, s := range in.Subnets {
|
||||
if !strings.Contains(s.CIDR, "/") {
|
||||
writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR")
|
||||
return
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.subnets = in
|
||||
writeJSON(w, http.StatusOK, in)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.runs
|
||||
if out == nil {
|
||||
out = []analyticsRun{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
rep, ok := f.reports[id]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "run not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(rep))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
key := r.PathValue("id") + "/" + r.PathValue("kind")
|
||||
if c := r.URL.Query().Get("class"); c != "" {
|
||||
key += "/" + c
|
||||
}
|
||||
l, ok := f.lists[key]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("format") == "csv" {
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`)
|
||||
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(l))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runOption is one entry of the run selector.
|
||||
type runOption struct {
|
||||
ID int64
|
||||
Label string
|
||||
Disabled bool
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type analyticsPageData struct {
|
||||
PageData
|
||||
Runs []runOption
|
||||
RunID int64
|
||||
PrevURL string // older run, "" when there is none
|
||||
NextURL string // newer run
|
||||
// DataJSON is the page's data for analytics.js (run meta, labels, report),
|
||||
// HTML-safe JSON.
|
||||
DataJSON template.JS
|
||||
HasRun bool
|
||||
}
|
||||
|
||||
// analyticsMeta is what analytics.js needs besides the report.
|
||||
type analyticsMeta struct {
|
||||
RunID int64 `json:"run_id"`
|
||||
Kind string `json:"kind"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Duration string `json:"duration"`
|
||||
Rechecked int `json:"rechecked"`
|
||||
ListURL string `json:"list_url"`
|
||||
CSVURL string `json:"csv_url"`
|
||||
Registry string `json:"registry_url"`
|
||||
}
|
||||
|
||||
// handleAnalyticsPage renders the analytics of one finished run: ?run=ID, by
|
||||
// default the newest finished run. The run selector lists every run, the open
|
||||
// one disabled; nothing of any other run is on the page.
|
||||
func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := analyticsPageData{}
|
||||
data.ActiveNav = "analytics"
|
||||
|
||||
runs, err := s.CA.ListAnalyticsRuns(r.Context())
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
want, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
var chosen *analyticsRun
|
||||
for i := range runs { // newest first
|
||||
if runs[i].State != "finalized" || runs[i].Addresses == 0 {
|
||||
continue
|
||||
}
|
||||
if want == 0 || runs[i].ID == want {
|
||||
chosen = &runs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
var finished []analyticsRun // newest first
|
||||
for _, x := range runs {
|
||||
if x.State == "finalized" && x.Addresses > 0 {
|
||||
finished = append(finished, x)
|
||||
}
|
||||
}
|
||||
for _, x := range runs {
|
||||
if x.State == "finalized" && x.Addresses == 0 {
|
||||
continue // nothing to show for it, hide
|
||||
}
|
||||
opt := runOption{ID: x.ID, Label: runLabel(x), Disabled: x.State != "finalized"}
|
||||
if chosen != nil && x.ID == chosen.ID {
|
||||
opt.Selected = true
|
||||
}
|
||||
data.Runs = append(data.Runs, opt)
|
||||
}
|
||||
if chosen == nil {
|
||||
if want != 0 {
|
||||
data.Banner = bannerData{Message: fmt.Sprintf("Запуск %d не найден или ещё не завершён.", want), Client: true}
|
||||
}
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
data.RunID = chosen.ID
|
||||
for i, x := range finished {
|
||||
if x.ID == chosen.ID {
|
||||
if i+1 < len(finished) {
|
||||
data.PrevURL = "/analytics?run=" + strconv.FormatInt(finished[i+1].ID, 10)
|
||||
}
|
||||
if i > 0 {
|
||||
data.NextURL = "/analytics?run=" + strconv.FormatInt(finished[i-1].ID, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID)
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
var info struct {
|
||||
Run struct {
|
||||
Rechecked int `json:"rechecked"`
|
||||
} `json:"run"`
|
||||
}
|
||||
_ = json.Unmarshal(report, &info)
|
||||
id := strconv.FormatInt(chosen.ID, 10)
|
||||
meta := analyticsMeta{
|
||||
RunID: chosen.ID, Kind: kindLabel(chosen.Kind),
|
||||
Start: fmtShort(chosen.StartedAt), Duration: "—", Rechecked: info.Run.Rechecked,
|
||||
ListURL: "/analytics/lists/",
|
||||
CSVURL: "/analytics/csv/",
|
||||
Registry: "/registry?run=" + id,
|
||||
}
|
||||
if chosen.FinalizedAt != nil {
|
||||
meta.End = fmtShort(*chosen.FinalizedAt)
|
||||
meta.Duration = fmtRunDuration(chosen.FinalizedAt.Sub(chosen.StartedAt))
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Meta analyticsMeta `json:"meta"`
|
||||
Report json.RawMessage `json:"report"`
|
||||
}{meta, report})
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
data.HasRun = true
|
||||
data.DataJSON = template.JS(payload)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
}
|
||||
|
||||
func parseRunParam(r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
// handleAnalyticsList proxies one address table of a run as JSON.
|
||||
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseRunParam(r)
|
||||
if !ok {
|
||||
http.Error(w, "run is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out, err := s.CA.GetAnalyticsList(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
|
||||
if err != nil {
|
||||
writeProxyError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
// handleAnalyticsCSV proxies the CSV file of one address table as a download.
|
||||
func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseRunParam(r)
|
||||
if !ok {
|
||||
http.Error(w, "run is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body, disposition, err := s.CA.GetAnalyticsListCSV(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
|
||||
if err != nil {
|
||||
writeProxyError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeProxyError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusBadGateway
|
||||
if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 {
|
||||
status = ae.Status
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
}
|
||||
|
||||
func kindLabel(kind string) string {
|
||||
if kind == "auto" {
|
||||
return "авто"
|
||||
}
|
||||
return "ручной"
|
||||
}
|
||||
|
||||
// groupThousands writes n with a space between thousands: 6440 -> "6 440".
|
||||
func groupThousands(n int) string {
|
||||
s := strconv.Itoa(n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fmtShort is a run timestamp as the page shows it: 02.10.2026 13:47.
|
||||
func fmtShort(t time.Time) string { return t.Local().Format("02.01.2006 15:04") }
|
||||
|
||||
// fmtDuration is "8 ч 42 мин", "42 мин", "под минуту".
|
||||
func fmtRunDuration(d time.Duration) string {
|
||||
m := int(d.Round(time.Minute) / time.Minute)
|
||||
switch {
|
||||
case m <= 0:
|
||||
return "меньше минуты"
|
||||
case m < 60:
|
||||
return fmt.Sprintf("%d мин", m)
|
||||
}
|
||||
return fmt.Sprintf("%d ч %d мин", m/60, m%60)
|
||||
}
|
||||
|
||||
// runLabel is the text of a run in the selector, e.g.
|
||||
// "02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass"; an open run reads
|
||||
// "03.10 15:30 → идёт · ручной · 120 из 800 · недоступен".
|
||||
func runLabel(x analyticsRun) string {
|
||||
start := x.StartedAt.Local().Format("02.01 15:04")
|
||||
if x.State != "finalized" {
|
||||
return fmt.Sprintf("%s → идёт · %s · %s из %s · недоступен", start, kindLabel(x.Kind),
|
||||
groupThousands(x.Total-x.Pending), groupThousands(x.Total))
|
||||
}
|
||||
end := "—"
|
||||
if x.FinalizedAt != nil {
|
||||
e := x.FinalizedAt.Local()
|
||||
if e.Format("02.01") == x.StartedAt.Local().Format("02.01") {
|
||||
end = e.Format("15:04")
|
||||
} else {
|
||||
end = e.Format("02.01 15:04")
|
||||
}
|
||||
}
|
||||
pass := 0
|
||||
if x.Addresses > 0 {
|
||||
pass = int(float64(x.Pass)/float64(x.Addresses)*100 + 0.5)
|
||||
}
|
||||
return fmt.Sprintf("%s → %s · %s · %s адр. · %d%% pass", start, end, kindLabel(x.Kind), groupThousands(x.Addresses), pass)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeRun(id int64, state string, addresses, pass int) analyticsRun {
|
||||
start := time.Date(2026, 10, 2, 13, 47, 0, 0, time.UTC)
|
||||
end := start.Add(8*time.Hour + 42*time.Minute)
|
||||
r := analyticsRun{ID: id, Kind: "manual", State: state, StartedAt: start, Addresses: addresses, Pass: pass,
|
||||
Partial: addresses - pass, Total: addresses}
|
||||
if state == "finalized" {
|
||||
r.FinalizedAt = &end
|
||||
} else {
|
||||
r.Pending = 80
|
||||
r.Total = addresses + r.Pending
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// reportWith is the minimal report JSON the page needs; addresses is a marker
|
||||
// that tells the runs apart in the page source.
|
||||
func reportWith(addresses string) string {
|
||||
return `{"run":{"rechecked":0},"summary":{"addresses":` + addresses + `,"pass":1,"partial":0,"fail":0,"cancelled":0,` +
|
||||
`"egress_ok":1,"ingress_ok":1,"egress_https_any_failed":0,"egress_https_all_failed":0,"egress_https_all_targets_failed":0,` +
|
||||
`"ingress_ssh_any_failed":0,"ingress_ssh_all_failed":0,"addresses_per_minute":1},"reasons":[],"quality":{},"subnets":[],` +
|
||||
`"targets":{"types":[],"targets":[],"failed":{}},"matrix":{},"sites":{"types":[],"rows":[]},"errors":[],"validators":[]}`
|
||||
}
|
||||
|
||||
func analyticsFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(2, "finalized", 900, 300), fakeRun(1, "finalized", 6440, 1962)}
|
||||
fake.reports = map[int64]string{1: reportWith("6440"), 2: reportWith("900")}
|
||||
fake.lists = map[string]string{
|
||||
"1/egress_https_any": `{"kind":"egress_https_any","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
|
||||
"1/error/SSH: таймаут": `{"kind":"error","class":"SSH: таймаут","columns":["Адрес"],"rows":[["1.2.3.4"]]}`,
|
||||
}
|
||||
return fake, newTestServer(t, caURL)
|
||||
}
|
||||
|
||||
// The page shows one run, by default the newest finished one, and asks
|
||||
// control-api for that run only; the selector lists every run, the open one
|
||||
// disabled.
|
||||
func TestAnalyticsPageShowsOneRun(t *testing.T) {
|
||||
fake, ts := analyticsFake(t)
|
||||
|
||||
page := get(t, ts, "/analytics")
|
||||
for _, want := range []string{
|
||||
`id="an-run"`, `id="analytics-data"`, `"addresses":900`, // newest finished run (2)
|
||||
"идёт · ручной · 120 из 200 · недоступен",
|
||||
"6 440 адр. · 30% pass", // run 1's option, from the run list
|
||||
`/analytics?run=1`, // the older run is one step back
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in the page, got:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, `"addresses":6440`) {
|
||||
t.Fatalf("the data of run 1 is on the page of run 2")
|
||||
}
|
||||
if !strings.Contains(page, `value="3" disabled`) {
|
||||
t.Fatalf("the open run must be listed but disabled:\n%s", page)
|
||||
}
|
||||
for _, r := range fake.analyticsReqs {
|
||||
if strings.Contains(r, "/runs/1") || strings.Contains(r, "/runs/3") {
|
||||
t.Fatalf("the page must request only the chosen run, got %v", fake.analyticsReqs)
|
||||
}
|
||||
}
|
||||
|
||||
other := get(t, ts, "/analytics?run=1")
|
||||
if !strings.Contains(other, `"addresses":6440`) || strings.Contains(other, `"addresses":900`) {
|
||||
t.Fatalf("run 1 page must carry only run 1's data:\n%s", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
page := get(t, ts, "/analytics")
|
||||
if !strings.Contains(page, "Запусков проверки пока нет") || strings.Contains(page, `id="analytics-data"`) {
|
||||
t.Fatalf("expected the empty state, got:\n%s", page)
|
||||
}
|
||||
|
||||
_, ts = analyticsFake(t)
|
||||
for _, run := range []string{"99", "3"} { // unknown, and the open one
|
||||
page = get(t, ts, "/analytics?run="+run)
|
||||
if !strings.Contains(page, "не найден или ещё не завершён") {
|
||||
t.Fatalf("run %s: expected a warning, got:\n%s", run, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsListProxyAndCSV(t *testing.T) {
|
||||
_, ts := analyticsFake(t)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/analytics/lists/egress_https_any?run=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"1.2.3.4"`) {
|
||||
t.Fatalf("list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
q := url.Values{"run": {"1"}, "class": {"SSH: таймаут"}}
|
||||
resp, err = http.Get(ts.URL + "/analytics/lists/error?" + q.Encode())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"class":"SSH: таймаут"`) {
|
||||
t.Fatalf("error list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, err = http.Get(ts.URL + "/analytics/csv/egress_https_any?run=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
|
||||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="egress_https_any_run1.csv"`) {
|
||||
t.Fatalf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
|
||||
for path, want := range map[string]int{
|
||||
"/analytics/lists/egress_https_any": http.StatusBadRequest, // no run
|
||||
"/analytics/csv/egress_https_any?run=abc": http.StatusBadRequest,
|
||||
"/analytics/lists/nonsense?run=1": http.StatusNotFound, // control-api says unknown list
|
||||
} {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sidebar: no window-chrome dots next to the logo, the Analytics entry,
|
||||
// and the link state, theme toggle and logout above the navigation.
|
||||
func TestSidebarSessionBlockOnTop(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
cookie := login(t, ts)
|
||||
_, page := doReq(t, ts, reqOpts{path: "/overview", cookie: cookie})
|
||||
|
||||
if strings.Contains(page, "brand-chrome") {
|
||||
t.Fatalf("the three dots next to the logo are back")
|
||||
}
|
||||
iBrand := strings.Index(page, `class="brand"`)
|
||||
iAPI := strings.Index(page, `class="session-api"`)
|
||||
iLogout := strings.Index(page, `action="/logout"`)
|
||||
iNav := strings.Index(page, `class="nav-groups"`)
|
||||
iFoot := strings.Index(page, "sidebar-foot")
|
||||
if !(iBrand >= 0 && iBrand < iAPI && iAPI < iLogout && iLogout < iNav) || iFoot >= 0 {
|
||||
t.Fatalf("expected brand < control-api state < logout < navigation (and no old footer): %d %d %d %d foot=%d", iBrand, iAPI, iLogout, iNav, iFoot)
|
||||
}
|
||||
for _, link := range []string{`href="/analytics"`, `href="/registry"`, `href="/settings"`} {
|
||||
if !strings.Contains(page, link) {
|
||||
t.Fatalf("missing nav link %s", link)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "pulse-dot down") {
|
||||
t.Fatalf("the link state must be fine while control-api answers")
|
||||
}
|
||||
}
|
||||
|
||||
// The link indicator turns red when control-api cannot be reached.
|
||||
func TestSidebarShowsLostControlAPI(t *testing.T) {
|
||||
ts := newTestServer(t, "http://127.0.0.1:1") // nothing listens there
|
||||
page := get(t, ts, "/overview")
|
||||
if !strings.Contains(page, "pulse-dot down") || !strings.Contains(page, "нет связи") {
|
||||
t.Fatalf("expected the lost-link indicator, got:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsSubnetsForm(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
body := postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"83.166.248.0/21 москва\n\n 10.0.0.0/8\n"}})
|
||||
if len(fake.subnets.Subnets) != 2 ||
|
||||
fake.subnets.Subnets[0] != (subnetEntry{CIDR: "83.166.248.0/21", Label: "москва"}) ||
|
||||
fake.subnets.Subnets[1] != (subnetEntry{CIDR: "10.0.0.0/8"}) {
|
||||
t.Fatalf("subnets saved: %+v", fake.subnets)
|
||||
}
|
||||
if !strings.Contains(body, "83.166.248.0/21 москва") || !strings.Contains(body, "10.0.0.0/8") {
|
||||
t.Fatalf("the saved list must come back in the form:\n%s", body)
|
||||
}
|
||||
|
||||
body = postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"not-a-cidr"}})
|
||||
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "not a CIDR") {
|
||||
t.Fatalf("expected the validation error in the banner:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestRegistryDrillDownFromAnalytics(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
page := get(t, ts, "/registry?run=2&subnet="+url.QueryEscape("10.0.0.0/24"))
|
||||
if len(fake.registryQueries) == 0 {
|
||||
t.Fatal("no registry request")
|
||||
}
|
||||
last := fake.registryQueries[len(fake.registryQueries)-1]
|
||||
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", "сбросить фильтр"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
|
||||
page = get(t, ts, "/registry?subnet=garbage")
|
||||
last = fake.registryQueries[len(fake.registryQueries)-1]
|
||||
if strings.Contains(last, "subnet=") || strings.Contains(page, "сбросить фильтр") {
|
||||
t.Fatalf("a malformed subnet must be ignored: %q", last)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package dashboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -11,6 +13,8 @@ type registryPageData struct {
|
||||
Items []registryItem
|
||||
Query string
|
||||
StatusFilter string
|
||||
Run int64 // drill-down from the analytics page
|
||||
Subnet string
|
||||
Page, PerPage int
|
||||
Total int
|
||||
Pager pagerData
|
||||
@@ -37,7 +41,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
||||
page := parsePage(r.URL.Query().Get("page"))
|
||||
query := registryQuery{Q: q, LastResult: status, Limit: perPage, Offset: (page - 1) * perPage}
|
||||
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}
|
||||
|
||||
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
||||
if err == nil {
|
||||
@@ -54,7 +66,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
if status != "" {
|
||||
params.Set("status", status)
|
||||
}
|
||||
if run > 0 {
|
||||
params.Set("run", strconv.FormatInt(run, 10))
|
||||
}
|
||||
if subnet != "" {
|
||||
params.Set("subnet", subnet)
|
||||
}
|
||||
data := registryPageData{
|
||||
Run: run,
|
||||
Subnet: subnet,
|
||||
Items: res.Items,
|
||||
Query: q,
|
||||
StatusFilter: status,
|
||||
|
||||
@@ -14,6 +14,8 @@ type settingsPageData struct {
|
||||
Inbound inboundChecksDTO
|
||||
// AutoCycle is the automatic-check-cycle panel's status/parameters.
|
||||
AutoCycle autoCycleDTO
|
||||
// Subnets is the list the analytics page groups addresses by.
|
||||
Subnets subnetList
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -26,7 +28,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
err = autoCycleErr
|
||||
}
|
||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
|
||||
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||
if err == nil {
|
||||
err = subnetsErr
|
||||
}
|
||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}
|
||||
data.ActiveNav = "settings"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "settings_page", data)
|
||||
@@ -49,7 +55,11 @@ func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, acti
|
||||
if actionErr == nil {
|
||||
actionErr = autoCycleErr
|
||||
}
|
||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}, actionErr)
|
||||
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||
if actionErr == nil {
|
||||
actionErr = subnetsErr
|
||||
}
|
||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}, actionErr)
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,6 +112,26 @@ func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
// handleSubnetsPut saves the subnet list from the textarea of /settings: one
|
||||
// subnet per line, CIDR first and an optional label after a space.
|
||||
func (s *Server) handleSubnetsPut(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
|
||||
return
|
||||
}
|
||||
list := subnetList{Subnets: []subnetEntry{}}
|
||||
for _, line := range strings.Split(r.PostFormValue("subnets"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cidr, label, _ := strings.Cut(line, " ")
|
||||
list.Subnets = append(list.Subnets, subnetEntry{CIDR: strings.TrimSpace(cidr), Label: strings.TrimSpace(label)})
|
||||
}
|
||||
_, err := s.CA.PutSubnets(r.Context(), list)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
// parseMinutes converts a form field holding a (possibly fractional) number
|
||||
// of minutes into whole seconds.
|
||||
func parseMinutes(raw string) (int, error) {
|
||||
|
||||
@@ -29,6 +29,10 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /registry", s.handleRegistryPage)
|
||||
mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail)
|
||||
|
||||
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
|
||||
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
|
||||
mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV)
|
||||
|
||||
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
|
||||
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
|
||||
mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate)
|
||||
@@ -52,6 +56,7 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /settings", s.handleSettingsPage)
|
||||
mux.HandleFunc("PUT /settings", s.handleSettingsPut)
|
||||
mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut)
|
||||
mux.HandleFunc("PUT /settings/subnets", s.handleSubnetsPut)
|
||||
mux.HandleFunc("PUT /settings/auto-cycle", s.handleAutoCyclePut)
|
||||
mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart)
|
||||
mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Analytics page. Everything is prefixed an-; the dashboard's own tokens
|
||||
(--surface, --border, --accent, ...) are used as they are. */
|
||||
:root {
|
||||
--an-radius: 4px;
|
||||
--an-shadow: 0 1px 2px rgba(12,18,30,.06), 0 1px 1px rgba(12,18,30,.05);
|
||||
--an-bar: #3987e5; --an-bar-track: #E4E9F0;
|
||||
/* heat ramp: one blue hue, validated sequential steps (light = low) */
|
||||
--an-h1:#cde2fb; --an-h2:#9ec5f4; --an-h3:#6da7ec; --an-h4:#3987e5; --an-h5:#256abf; --an-h6:#184f95; --an-h7:#0d366b;
|
||||
--an-hi1:#12161F; --an-hi2:#12161F; --an-hi3:#12161F; --an-hi4:#FFFFFF; --an-hi5:#FFFFFF; --an-hi6:#FFFFFF; --an-hi7:#FFFFFF;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--an-shadow: 0 1px 2px rgba(0,0,0,.4);
|
||||
--an-bar: #5C8CFF; --an-bar-track: #1D2430;
|
||||
--an-h1:#0d366b; --an-h2:#104281; --an-h3:#184f95; --an-h4:#256abf; --an-h5:#3987e5; --an-h6:#6da7ec; --an-h7:#9ec5f4;
|
||||
--an-hi1:#B8C9E8; --an-hi2:#C3D6F5; --an-hi3:#E7ECF5; --an-hi4:#FFFFFF; --an-hi5:#0A0D13; --an-hi6:#0A0D13; --an-hi7:#0A0D13;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--an-shadow: 0 1px 2px rgba(0,0,0,.4);
|
||||
--an-bar: #5C8CFF; --an-bar-track: #1D2430;
|
||||
--an-h1:#0d366b; --an-h2:#104281; --an-h3:#184f95; --an-h4:#256abf; --an-h5:#3987e5; --an-h6:#6da7ec; --an-h7:#9ec5f4;
|
||||
--an-hi1:#B8C9E8; --an-hi2:#C3D6F5; --an-hi3:#E7ECF5; --an-hi4:#FFFFFF; --an-hi5:#0A0D13; --an-hi6:#0A0D13; --an-hi7:#0A0D13;
|
||||
}
|
||||
|
||||
.an { display: grid; gap: 16px; align-content: start; min-width: 0; font-family: var(--font-ui); font-size: 14px; line-height: 1.5; }
|
||||
.an * { box-sizing: border-box; }
|
||||
.an-title { font: 700 18px var(--font-mono); margin: 0; }
|
||||
.an h2 { font: 700 12px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: 0; }
|
||||
.an-sub { color: var(--text-muted); font-size: 12.5px; margin: 0; }
|
||||
.an-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--an-radius); box-shadow: var(--an-shadow); padding: 14px 16px; display: grid; gap: 12px; min-width: 0; align-content: start; }
|
||||
.an-panel > * { min-width: 0; }
|
||||
.an-head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; justify-content: space-between; }
|
||||
.an-runbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.an-runbar label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.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-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; }
|
||||
.an-tabs button:first-child { border-radius: var(--an-radius) 0 0 var(--an-radius); } .an-tabs button:last-child { border-radius: 0 var(--an-radius) var(--an-radius) 0; }
|
||||
.an-tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent); }
|
||||
.an-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 12px; }
|
||||
.an-kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--an-radius); padding: 12px 14px; display: grid; gap: 2px; box-shadow: var(--an-shadow); }
|
||||
.an-kpi .an-k { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
|
||||
.an-kpi .an-v { font: 700 26px var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.an-kpi .an-d { font-size: 12px; color: var(--text-muted); }
|
||||
.an-tag { display: inline-flex; align-items: center; gap: 5px; font: 700 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: .03em; padding: 2px 7px; border-radius: 2px; }
|
||||
.an-tag::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 1px; }
|
||||
.an-t-ok { background: var(--success-soft); color: var(--success); } .an-t-warn { background: var(--warning-soft); color: var(--warning); } .an-t-bad { background: var(--danger-soft); color: var(--danger); } .an-t-n { background: var(--neutral-soft); color: var(--neutral); }
|
||||
.an-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
|
||||
.an-rows { display: grid; gap: 7px; }
|
||||
.an-bar-row { display: grid; grid-template-columns: minmax(110px, 38%) minmax(0,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.an-bar-row .an-n { overflow-wrap: anywhere; }
|
||||
.an-track { height: 12px; background: var(--an-bar-track); border-radius: 2px; position: relative; }
|
||||
.an-fill { height: 100%; background: var(--an-bar); border-radius: 0 4px 4px 0; }
|
||||
.an-num { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
.an-scroll { overflow-x: auto; }
|
||||
.an table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
.an th .an-hint { cursor: help; color: var(--accent-strong); margin-left: 3px; }
|
||||
.an th { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.an th.an-r, .an td.an-r { text-align: right; }
|
||||
.an td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||||
.an td.an-a { font-family: var(--font-mono); font-size: 12.5px; white-space: nowrap; }
|
||||
.an td.an-a a { color: var(--accent-strong); text-decoration: none; } td.an-a a:hover { text-decoration: underline; }
|
||||
.an-pb { display: grid; grid-template-columns: 70px 40px; gap: 6px; align-items: center; justify-content: end; }
|
||||
.an-pb .an-track { height: 8px; }
|
||||
.an-heat td.an-c { padding: 2px; }
|
||||
.an-cell { display: block; min-width: 76px; text-align: center; font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; padding: 7px 4px; border-radius: 2px; cursor: default; }
|
||||
.an-h1{background:var(--an-h1);color:var(--an-hi1)} .an-h2{background:var(--an-h2);color:var(--an-hi2)} .an-h3{background:var(--an-h3);color:var(--an-hi3)} .an-h4{background:var(--an-h4);color:var(--an-hi4)} .an-h5{background:var(--an-h5);color:var(--an-hi5)} .an-h6{background:var(--an-h6);color:var(--an-hi6)} .an-h7{background:var(--an-h7);color:var(--an-hi7)}
|
||||
.an-legend { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--text-muted); }
|
||||
.an-legend i { display: inline-block; width: 28px; height: 10px; border-radius: 2px; }
|
||||
.an-vals { display: flex; align-items: flex-end; gap: 4px; height: 120px; border-bottom: 1px solid var(--border); position: relative; }
|
||||
.an-vals .an-col { flex: 1 1 0; min-width: 0; background: var(--an-bar); border-radius: 3px 3px 0 0; }
|
||||
.an-vals .an-avg { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--text-muted); }
|
||||
.an-vlab { display: flex; justify-content: space-between; font: 500 11px var(--font-mono); color: var(--text-faint); }
|
||||
.an-dq { display: grid; gap: 8px; }
|
||||
.an-dq .an-row { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--border-soft); padding-bottom: 6px; font-size: 13px; }
|
||||
.an-dq .an-row span:last-child { font: 700 13px var(--font-mono); white-space: nowrap; }
|
||||
.an-note { font-size: 12px; color: var(--text-muted); }
|
||||
button.an-kpi { font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%; }
|
||||
button.an-kpi:hover { border-color: var(--accent); }
|
||||
button.an-kpi:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.an-kpi .an-go { font: 700 11px var(--font-mono); color: var(--accent-strong); }
|
||||
.an dialog { width: min(760px, calc(100% - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
|
||||
.an dialog[open] { display: flex; flex-direction: column; }
|
||||
.an dialog::backdrop { background: rgba(10,14,20,.5); }
|
||||
.an-dlg { display: flex; flex-direction: column; gap: 12px; padding: 16px; min-height: 0; flex: 1 1 auto; max-height: calc(100vh - 34px); max-height: calc(100dvh - 34px); }
|
||||
.an-dlg h3 { font: 700 15px var(--font-mono); margin: 0; overflow-wrap: anywhere; flex: 0 0 auto; }
|
||||
.an-dlg .an-note { flex: 0 0 auto; max-height: 6.5em; overflow: auto; margin: 0; }
|
||||
.an-dlg .an-dist { flex: 0 0 auto; }
|
||||
.an-dlg .an-list { flex: 0 1 auto; min-height: 96px; overflow: auto; border: 1px solid var(--border-soft); border-radius: var(--an-radius); }
|
||||
.an-dlg table { font-size: 12.5px; } .an-dlg th { position: sticky; top: 0; background: var(--surface); }
|
||||
.an-dlg .an-foot { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding-top: 4px; border-top: 1px solid var(--border-soft); }
|
||||
.an-dlg .an-foot .an-acts { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 520px) { .an-dlg { padding: 12px; gap: 10px; } .an-dlg .an-foot .an-acts { width: 100%; } .an-dlg .an-foot .an-acts .an-btn { flex: 1 1 auto; } }
|
||||
@media (max-height: 540px) { .an-dlg .an-dist { display: none; } }
|
||||
@media (max-height: 640px) { .an-dist .an-cols20 { height: 44px; } .an-dlg .an-note { max-height: 3em; } .an-dlg .an-dist .an-chips { display: none; } }
|
||||
.an-btn.an-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.an-btn.an-primary:hover { background: var(--accent-strong); }
|
||||
button.an-bar-row { font: inherit; color: inherit; text-align: left; width: 100%; background: none; border: 0; border-radius: var(--an-radius); padding: 3px 4px; margin: -3px -4px; cursor: pointer; }
|
||||
button.an-bar-row:hover { background: var(--surface-alt); }
|
||||
button.an-bar-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.an-dist { display: grid; gap: 8px; }
|
||||
.an-dist .an-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.an-dist .an-cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
|
||||
.an-dist .an-cols20 div { background: var(--an-bar); border-radius: 2px 2px 0 0; min-height: 1px; }
|
||||
.an-dist .an-lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
|
||||
#an-tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
|
||||
@media (prefers-reduced-motion: reduce) { #an-tip { transition: none; } }
|
||||
|
||||
/* dashboard.css turns every table into stacked cards on a phone; the tables on
|
||||
this page are data tables and scroll sideways instead. */
|
||||
@media (max-width: 640px) {
|
||||
.an-kpi .an-v { font-size: 22px; }
|
||||
.an thead { display: table-header-group; }
|
||||
.an table { display: table; width: 100%; }
|
||||
.an tbody { display: table-row-group; width: auto; }
|
||||
.an tr, .an tbody tr { display: table-row; width: auto; padding: 0; border-bottom: 0; }
|
||||
.an td { display: table-cell; width: auto; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/* 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 a dialog. No other run's data is on the page. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var D = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
|
||||
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
|
||||
function pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
|
||||
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; }
|
||||
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] || '') };
|
||||
|
||||
/* ---- indicators ---- */
|
||||
function renderKpis() {
|
||||
var late = Q.late_failed_checks_at_pass;
|
||||
var tiles = [
|
||||
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
|
||||
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>', ''],
|
||||
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>', ''],
|
||||
['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной',
|
||||
S.fail ? '<span class="an-tag an-t-bad">провал</span>' : '<span class="an-tag an-t-n">нет</span>', ''],
|
||||
['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''],
|
||||
['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''],
|
||||
['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна',
|
||||
'<span class="an-tag an-t-warn">прикладной</span> <span class="an-go">список →</span>', 'egress_https_any'],
|
||||
['Ingress ssh: есть провалы', fmt(S.ingress_ssh_any_failed), pct1(S.ingress_ssh_any_failed, S.addresses) + '% адресов, хотя бы с одной площадки',
|
||||
'<span class="an-tag an-t-warn">прикладной</span> <span class="an-go">список →</span>', 'ingress_ssh_any'],
|
||||
['Egress https: все провалены', fmt(S.egress_https_all_failed), pct1(S.egress_https_all_failed, S.addresses) + '% адресов, по всем целям: ' + fmt(S.egress_https_all_targets_failed),
|
||||
'<span class="an-tag an-t-bad">прикладной</span> <span class="an-go">список →</span>', 'egress_https_all'],
|
||||
['Ingress ssh: все провалены', fmt(S.ingress_ssh_all_failed), pct1(S.ingress_ssh_all_failed, S.addresses) + '% адресов, ssh провален со всех площадок',
|
||||
'<span class="an-tag an-t-bad">прикладной</span> <span class="an-go">список →</span>', 'ingress_ssh_all'],
|
||||
['Длительность', esc(M.duration), fmt(S.addresses_per_minute) + ' адр./мин', '', ''],
|
||||
['Поздние результаты', fmt(late), 'у ' + fmt(Q.late_failed_addresses_at_pass) + ' адресов, после вердикта',
|
||||
late ? '<span class="an-tag an-t-warn">внимание</span>' : '<span class="an-tag an-t-ok">в норме</span>', '']
|
||||
];
|
||||
$('an-kpis').innerHTML = tiles.map(function (t) {
|
||||
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + t[1] + '</div><div class="an-d">' + t[2] + ' ' + t[3] + '</div>';
|
||||
return t[4]
|
||||
? '<button type="button" class="an-kpi" data-list="' + t[4] + '" aria-haspopup="dialog">' + inner + '</button>'
|
||||
: '<div class="an-kpi">' + inner + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function bar(v, max) {
|
||||
return '<div class="an-track"><div class="an-fill" style="width:' + (max ? Math.max(1, v / max * 100) : 0) + '%"></div></div>';
|
||||
}
|
||||
|
||||
function renderReasons() {
|
||||
var max = Math.max.apply(null, R.reasons.map(function (x) { return x.count; }).concat([1]));
|
||||
$('an-reasons').innerHTML = R.reasons.map(function (x) {
|
||||
return '<div class="an-bar-row" ' + tipAttr(x.name + ': ' + fmt(x.count) + ' адр. (' + pct(x.count, S.partial) + '% от partial)') + '><span class="an-n">' + esc(x.name) + '</span>' + bar(x.count, max) + '<span class="an-num">' + fmt(x.count) + '</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderQuality() {
|
||||
var errTotal = Q.ingress_failed_checks;
|
||||
var rows = [
|
||||
['Поздние провалы у адресов с вердиктом pass', fmt(Q.late_failed_checks_at_pass) + ' / ' + fmt(Q.late_failed_addresses_at_pass) + ' адр.'],
|
||||
['Провалы ingress, пришедшие после вердикта', fmt(Q.ingress_failed_late) + ' из ' + fmt(errTotal)],
|
||||
['Неполный набор проверок', fmt(Q.incomplete_addresses) + ' адр.'],
|
||||
['Вердикт pass, но есть проваленные проверки', fmt(Q.pass_with_failed_addresses) + ' адр.'],
|
||||
['pass по вердикту → по фактическим проверкам', fmt(S.pass) + ' → ' + fmt(Q.pass_by_facts)]
|
||||
];
|
||||
$('an-dq').innerHTML = rows.map(function (r) { return '<div class="an-row"><span>' + r[0] + '</span><span>' + r[1] + '</span></div>'; }).join('');
|
||||
}
|
||||
|
||||
function pbar(a, n) {
|
||||
var p = pct(a, n);
|
||||
return '<div class="an-pb"><div class="an-track"><div class="an-fill" style="width:' + p + '%"></div></div><span class="an-num">' + p + '%</span></div>';
|
||||
}
|
||||
|
||||
function renderSubnets() {
|
||||
var list = R.subnets.slice();
|
||||
list.sort(state.sort === 'worst'
|
||||
? function (a, b) { return a.pass / a.addresses - b.pass / b.addresses || b.addresses - a.addresses; }
|
||||
: function (a, b) { return b.addresses - a.addresses; });
|
||||
var shown = state.all ? list : list.slice(0, 10);
|
||||
$('an-allsub').textContent = state.all ? 'свернуть до 10' : 'показать все ' + list.length;
|
||||
$('an-allsub').hidden = list.length <= 10;
|
||||
$('an-subtbl').innerHTML = '<thead><tr><th>Подсеть</th><th class="an-r">Адресов</th><th class="an-r">pass</th><th class="an-r">Egress OK</th><th class="an-r">Ingress OK</th></tr></thead><tbody>' +
|
||||
shown.map(function (s) {
|
||||
var name = s.label ? s.cidr + ' · ' + s.label : s.cidr;
|
||||
var link = s.cidr === 'прочие' ? esc(name) : '<a href="' + M.registry_url + '&subnet=' + encodeURIComponent(s.cidr) + '" title="Открыть в реестре: запуск ' + M.run_id + ', подсеть ' + esc(s.cidr) + '">' + esc(name) + '</a>';
|
||||
return '<tr><td class="an-a">' + link + '</td><td class="an-num an-r">' + fmt(s.addresses) + '</td>' +
|
||||
'<td class="an-r" ' + tipAttr(s.cidr + ': pass ' + fmt(s.pass) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.pass, s.addresses) + '</td>' +
|
||||
'<td class="an-r" ' + tipAttr(s.cidr + ': egress OK ' + fmt(s.egress_ok) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.egress_ok, s.addresses) + '</td>' +
|
||||
'<td class="an-r" ' + tipAttr(s.cidr + ': ingress OK ' + fmt(s.ingress_ok) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.ingress_ok, s.addresses) + '</td></tr>';
|
||||
}).join('') + '</tbody>';
|
||||
}
|
||||
|
||||
function heatClass(v) { return 'an-h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); }
|
||||
|
||||
function renderTypes() {
|
||||
$('an-types').innerHTML = R.targets.types.map(function (t) {
|
||||
return '<button type="button" data-type="' + esc(t) + '" aria-pressed="' + (t === state.type) + '">' + esc(t) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderTargets() {
|
||||
var t = state.type, T = R.targets, vals = T.failed[t] || [];
|
||||
var max = Math.max.apply(null, vals.concat([1]));
|
||||
$('an-targets').innerHTML = T.targets.map(function (n, i) {
|
||||
return '<div class="an-bar-row" ' + tipAttr(n + ' · ' + t + ': провал у ' + fmt(vals[i]) + ' адр. (' + pct(vals[i], S.addresses) + '% всех)') + '><span class="an-n">' + esc(n) + '</span>' + bar(vals[i], max) + '<span class="an-num">' + fmt(vals[i]) + ' · ' + pct(vals[i], S.addresses) + '%</span></div>';
|
||||
}).join('');
|
||||
var rows = (R.matrix[t] || []).slice(0, state.all ? 100000 : 10);
|
||||
$('an-matrixwrap').hidden = !rows.length;
|
||||
$('an-hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map(function (l, i) {
|
||||
return '<span><i style="background:var(--an-h' + (i + 1) + ')"></i> ' + l + '%</span>';
|
||||
}).join('');
|
||||
$('an-matrix').innerHTML = '<thead><tr><th>Подсеть</th><th class="an-r">partial</th>' + T.targets.map(function (x) {
|
||||
return '<th class="an-r">' + esc(x.replace('.com', '').replace('.org', '')) + '</th>';
|
||||
}).join('') + '</tr></thead><tbody>' + rows.map(function (m) {
|
||||
var link = m.cidr === 'прочие' ? esc(m.cidr) : '<a href="' + M.registry_url + '&subnet=' + encodeURIComponent(m.cidr) + '" title="Открыть адреса в реестре">' + esc(m.cidr) + '</a>';
|
||||
return '<tr><td class="an-a">' + link + '</td><td class="an-num an-r">' + fmt(m.partial) + '</td>' + m.percent.map(function (v, j) {
|
||||
return '<td class="an-c"><span class="an-cell ' + heatClass(v) + '" ' + tipAttr(m.cidr + ' → ' + T.targets[j] + ': ' + v + '% адресов partial провалили ' + t) + '>' + v + '%</span></td>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('') + '</tbody>';
|
||||
$('an-matrixnote').textContent = 'Доля адресов partial подсети, провалившие ' + t + ' к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.';
|
||||
}
|
||||
|
||||
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>' +
|
||||
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>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('') + '</tbody>';
|
||||
}
|
||||
|
||||
function renderErrors() {
|
||||
var max = Math.max.apply(null, R.errors.map(function (e) { return e.count; }).concat([1]));
|
||||
$('an-errs').innerHTML = R.errors.length ? R.errors.map(function (e) {
|
||||
return '<button type="button" class="an-bar-row" data-cls="' + esc(e.name) + '" aria-haspopup="dialog" ' + tipAttr(e.name + ': ' + fmt(e.count) + ' проверок. Нажмите, чтобы открыть список') + '><span class="an-n">' + esc(e.name) + '</span>' + bar(e.count, max) + '<span class="an-num">' + fmt(e.count) + '</span></button>';
|
||||
}).join('') : '<p class="an-note">Проваленных ingress-проверок нет.</p>';
|
||||
}
|
||||
|
||||
function renderValidators() {
|
||||
var V = R.validators;
|
||||
var f = V.map(function (v) { return v.total ? 1 - v.ok / v.total : 0; });
|
||||
var avg = f.length ? f.reduce(function (a, b) { return a + b; }, 0) / f.length : 0;
|
||||
$('an-vals').innerHTML = f.map(function (x, i) {
|
||||
return '<div class="an-col" style="height:' + (x * 100) + '%" ' + tipAttr(vshort(V[i].validator) + ': провал ' + Math.round(x * 100) + '% из ' + fmt(V[i].total) + ' https-проверок') + '></div>';
|
||||
}).join('') + '<div class="an-avg" style="bottom:' + (avg * 100) + '%"></div>';
|
||||
$('an-vfirst').textContent = V.length ? 'валидатор ' + vshort(V[0].validator) : '';
|
||||
$('an-vlast').textContent = V.length > 1 ? 'валидатор ' + vshort(V[V.length - 1].validator) : '';
|
||||
$('an-vavg').textContent = V.length ? 'среднее ' + Math.round(avg * 100) + '% (шкала 0–100%)' : 'нет данных';
|
||||
}
|
||||
|
||||
/* ---- address lists in the dialog ---- */
|
||||
var VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.';
|
||||
var STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.';
|
||||
var VERDICT_VAL = {
|
||||
pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.',
|
||||
partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.',
|
||||
fail: 'fail: все проверки адреса провалены.'
|
||||
};
|
||||
function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; }
|
||||
|
||||
var LISTS = {
|
||||
egress_https_any: {
|
||||
title: 'Egress https: адреса с проваленными проверками',
|
||||
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.',
|
||||
hints: { 2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок адреса провалено из всех записанных в цикле.' }
|
||||
},
|
||||
ingress_ssh_any: {
|
||||
title: 'Ingress ssh: адреса с проваленными проверками',
|
||||
note: 'ssh провален хотя бы с одной площадки в последнем цикле запуска.',
|
||||
hints: { 2: 'Сколько площадок не смогли выполнить ssh-проверку адреса из всех, где она выполнялась.', 3: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 4: 'Класс ошибки: таймаут, баннер «Not allowed», нет маршрута.' }
|
||||
},
|
||||
egress_https_all: {
|
||||
title: 'Egress https: адреса, провалившие все проверки',
|
||||
note: 'Все записанные egress-проверки https адреса провалены в последнем цикле запуска.',
|
||||
hints: { 2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок записано у адреса в цикле. Все они провалены. Меньше полного набора значит, что часть результатов не пришла.' }
|
||||
},
|
||||
ingress_ssh_all: {
|
||||
title: 'Ingress ssh: адреса, провалившие все проверки',
|
||||
note: 'ssh провален со всех площадок, на которых он проверялся, в последнем цикле запуска.',
|
||||
hints: { 2: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 3: 'Класс ошибки ssh-проверки: таймаут, баннер «Not allowed», нет маршрута.' }
|
||||
}
|
||||
};
|
||||
|
||||
var cur = null; // the list shown in the dialog
|
||||
|
||||
function listURL(base, kind, cls) {
|
||||
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
|
||||
}
|
||||
|
||||
function csvText(cols, rows) {
|
||||
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
|
||||
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
var d = $('an-dlg');
|
||||
if (d.open) return;
|
||||
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
||||
}
|
||||
|
||||
function fillDialog(m) {
|
||||
cur = m;
|
||||
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
|
||||
$('an-dlg-note').textContent = 'Запуск: ' + m.runLabel + '. ' + m.note;
|
||||
$('an-dlg-dist').hidden = !m.dist;
|
||||
$('an-dlg-dist').innerHTML = m.dist || '';
|
||||
var hint = m.hints || {};
|
||||
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
|
||||
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
|
||||
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
|
||||
return '<tr>' + x.map(function (v, i) {
|
||||
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
|
||||
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('') + '</tbody>';
|
||||
$('an-dlg-msg').textContent = '';
|
||||
}
|
||||
|
||||
function loadList(kind, cls, meta) {
|
||||
$('an-dlg-title').textContent = meta.title;
|
||||
$('an-dlg-note').textContent = 'Загрузка…';
|
||||
$('an-dlg-dist').hidden = true;
|
||||
$('an-dlg-tbl').innerHTML = '';
|
||||
$('an-dlg-msg').textContent = '';
|
||||
cur = null;
|
||||
openDialog();
|
||||
return fetch(listURL(M.list_url, kind, cls), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (l) { return l; })
|
||||
.catch(function (e) {
|
||||
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function runLabel() {
|
||||
var o = document.getElementById('an-run');
|
||||
return o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id;
|
||||
}
|
||||
|
||||
function openIndicator(kind) {
|
||||
var meta = LISTS[kind];
|
||||
loadList(kind, '', meta).then(function (l) {
|
||||
if (!l) return;
|
||||
fillDialog({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), file: kind, kind: kind, cls: '' });
|
||||
});
|
||||
}
|
||||
|
||||
function openError(cls) {
|
||||
loadList('error', cls, { title: 'Ingress: ' + cls }).then(function (l) {
|
||||
if (!l) return;
|
||||
var rows = l.rows, labels = R.validators.map(function (v) { return vshort(v.validator); });
|
||||
rows.forEach(function (x) { if (labels.indexOf(x[3]) < 0) labels.push(x[3]); });
|
||||
var byVal = labels.map(function (lab) { return rows.filter(function (x) { return x[3] === lab; }).length; });
|
||||
var max = Math.max.apply(null, byVal.concat([1]));
|
||||
var late = rows.filter(function (x) { return x[5].indexOf('после') >= 0; }).length;
|
||||
var pass = rows.filter(function (x) { return x[4] === 'pass'; }).length;
|
||||
var grid = 'grid-template-columns:repeat(' + labels.length + ',minmax(0,1fr))';
|
||||
var dist = '<h2>Распределение по валидаторам</h2>' +
|
||||
'<div class="an-cols20" style="' + grid + '" role="img" aria-label="Число проваленных проверок по валидаторам">' + byVal.map(function (v, i) {
|
||||
return '<div style="height:' + Math.max(1, v / max * 100) + '%" ' + tipAttr('валидатор ' + labels[i] + ': ' + v + ' проверок') + '></div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="an-lab20" style="' + grid + '">' + labels.map(function (lab) { return '<span>' + esc(lab.replace(/^v/, '')) + '</span>'; }).join('') + '</div>' +
|
||||
'<div class="an-chips"><span class="an-tag an-t-n">всего ' + fmt(rows.length) + '</span><span class="an-tag an-t-ok">вердикт pass: ' + fmt(pass) + '</span><span class="an-tag an-t-warn">вердикт partial: ' + fmt(rows.length - pass) + '</span><span class="an-tag an-t-bad">после вердикта: ' + fmt(late) + '</span><span class="an-tag an-t-n">в вердикте: ' + fmt(rows.length - late) + '</span></div>';
|
||||
fillDialog({
|
||||
title: 'Ingress: ' + cls,
|
||||
note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
|
||||
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls,
|
||||
hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT },
|
||||
cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function copyCsv() {
|
||||
var msg = $('an-dlg-msg');
|
||||
if (!cur) return;
|
||||
var text = csvText(cur.cols, cur.rows);
|
||||
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
|
||||
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
|
||||
}
|
||||
|
||||
function downloadCsv() {
|
||||
if (!cur) return;
|
||||
window.location.href = listURL(M.csv_url, cur.kind, cur.cls);
|
||||
}
|
||||
|
||||
/* ---- wiring ---- */
|
||||
function renderAll() {
|
||||
renderSubnets();
|
||||
renderTypes();
|
||||
renderTargets();
|
||||
}
|
||||
|
||||
$('an-runnote').textContent = 'Тип: ' + M.kind + '. Начало ' + M.start + ', завершён ' + M.end + ', длительность ' + M.duration + '.' +
|
||||
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '');
|
||||
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll();
|
||||
|
||||
$('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); });
|
||||
document.querySelectorAll('[data-sort]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.sort = b.dataset.sort;
|
||||
document.querySelectorAll('[data-sort]').forEach(function (x) { x.setAttribute('aria-pressed', x === b); });
|
||||
renderSubnets();
|
||||
});
|
||||
});
|
||||
$('an-types').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('[data-type]');
|
||||
if (!b) return;
|
||||
state.type = b.dataset.type;
|
||||
renderTypes(); renderTargets();
|
||||
});
|
||||
$('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); renderTargets(); });
|
||||
$('an-dlg-csv').addEventListener('click', downloadCsv);
|
||||
$('an-dlg-copy').addEventListener('click', copyCsv);
|
||||
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
|
||||
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
|
||||
|
||||
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
|
||||
var tip = $('an-tip');
|
||||
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
var t = e.target.closest('[data-tip]');
|
||||
if (!t) return;
|
||||
var h = tipHost();
|
||||
if (tip.parentNode !== h) h.appendChild(tip);
|
||||
tip.textContent = t.dataset.tip;
|
||||
tip.style.opacity = 1;
|
||||
});
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
|
||||
tip.style.top = (e.clientY + 16) + 'px';
|
||||
});
|
||||
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
|
||||
})();
|
||||
@@ -180,11 +180,9 @@ main > h2.section-title:first-child { margin-top: 0; }
|
||||
padding: 18px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 14px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.brand-chrome { display: flex; gap: 4px; }
|
||||
.brand-chrome i { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
|
||||
.brand-mark {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -218,8 +216,10 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
||||
.nav-link.active svg { opacity: 1; }
|
||||
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
/* Session block at the top of the sidebar: link state to control-api, theme
|
||||
toggle and, with login enabled, who is signed in and the logout button. */
|
||||
.sidebar-session {
|
||||
display: grid; gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
@@ -227,9 +227,16 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 7px;
|
||||
}
|
||||
.sidebar-foot-status { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; margin: 0; }
|
||||
.session-api { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-user { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-group-title {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 10px;
|
||||
letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint);
|
||||
padding: 12px 9px 4px;
|
||||
}
|
||||
nav.nav-groups > .nav-group-title:first-child { padding-top: 0; }
|
||||
.pulse-dot {
|
||||
width: 6px; height: 6px; border-radius: 1px;
|
||||
background: var(--success);
|
||||
@@ -238,6 +245,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
||||
animation: pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
|
||||
.pulse-dot.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); animation: none; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
.theme-toggle {
|
||||
@@ -568,13 +576,6 @@ textarea.autosize { overflow-y: hidden; resize: none; }
|
||||
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
|
||||
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
|
||||
.login-card .field { flex: 0 0 auto; }
|
||||
.sidebar-user {
|
||||
margin-top: auto; margin-bottom: 8px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
font-family: var(--font-display); font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-user + .sidebar-foot { margin-top: 0; }
|
||||
|
||||
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
|
||||
progress {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
{{define "analytics_page"}}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<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">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "analytics_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{{if .HasRun}}
|
||||
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
|
||||
<script src="/static/analytics.js"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "analytics_content"}}
|
||||
<div class="an">
|
||||
<div class="an-head">
|
||||
<h1 class="an-title">Аналитика</h1>
|
||||
<p class="an-sub">Один выбранный запуск. Данные других запусков на странице не участвуют.</p>
|
||||
</div>
|
||||
|
||||
{{if not .Runs}}
|
||||
<section class="an-panel">
|
||||
<p class="an-note">Запусков проверки пока нет. Они появляются, когда адреса ставятся в очередь на странице <a href="/ips">«Очередь IP»</a> или запускается автоматический цикл.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="an-panel" aria-label="Выбор запуска">
|
||||
<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)">
|
||||
{{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}}
|
||||
</div>
|
||||
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasRun}}
|
||||
<div class="an-kpis" id="an-kpis"></div>
|
||||
|
||||
<div class="an-cols">
|
||||
<section class="an-panel" aria-labelledby="an-h-reasons">
|
||||
<h2 id="an-h-reasons">Почему partial</h2>
|
||||
<div class="an-rows" id="an-reasons"></div>
|
||||
<p class="an-note">Адрес считается один раз, по главной причине. Всё, где есть egress, учитывается как egress.</p>
|
||||
</section>
|
||||
<section class="an-panel" aria-labelledby="an-h-dq">
|
||||
<h2 id="an-h-dq">Качество данных</h2>
|
||||
<div class="an-dq" id="an-dq"></div>
|
||||
<p class="an-note">Вердикт ставится при агрегации. Результаты, пришедшие позже, остаются в этом же запуске, но в вердикт не входят.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="an-panel" aria-labelledby="an-h-sub">
|
||||
<div class="an-head">
|
||||
<h2 id="an-h-sub">Подсети</h2>
|
||||
<div class="an-runbar">
|
||||
<div class="an-tabs" role="group" aria-label="Сортировка подсетей">
|
||||
<button type="button" data-sort="worst" aria-pressed="true">хуже всего</button>
|
||||
<button type="button" data-sort="size" aria-pressed="false">больше адресов</button>
|
||||
</div>
|
||||
<button class="an-btn" id="an-allsub" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="an-scroll"><table id="an-subtbl"></table></div>
|
||||
<p class="an-note">Строка ведёт в «Реестр» с фильтром по запуску и подсети.</p>
|
||||
</section>
|
||||
|
||||
<section class="an-panel" 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>
|
||||
</div>
|
||||
<div class="an-rows" id="an-targets"></div>
|
||||
<div id="an-matrixwrap">
|
||||
<div class="an-head"><h2 style="margin-top:6px">Подсеть × цель</h2>
|
||||
<div class="an-legend" id="an-hlegend"></div></div>
|
||||
<div class="an-scroll"><table class="an-heat" id="an-matrix"></table></div>
|
||||
</div>
|
||||
<p class="an-note" id="an-matrixnote"></p>
|
||||
</section>
|
||||
|
||||
<div class="an-cols">
|
||||
<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>
|
||||
</section>
|
||||
<section class="an-panel" aria-labelledby="an-h-err">
|
||||
<h2 id="an-h-err">Классы ошибок ingress</h2>
|
||||
<div class="an-rows" id="an-errs"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="an-panel" 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>
|
||||
|
||||
<dialog id="an-dlg" aria-labelledby="an-dlg-title">
|
||||
<div class="an-dlg">
|
||||
<h3 id="an-dlg-title"></h3>
|
||||
<p class="an-note" id="an-dlg-note"></p>
|
||||
<div class="an-dist" id="an-dlg-dist" hidden></div>
|
||||
<div class="an-list"><table id="an-dlg-tbl"></table></div>
|
||||
<div class="an-foot">
|
||||
<span class="an-note" id="an-dlg-msg" role="status"></span>
|
||||
<div class="an-acts">
|
||||
<button class="an-btn" type="button" id="an-dlg-copy">Копировать</button>
|
||||
<button class="an-btn an-primary" type="button" id="an-dlg-csv">Скачать CSV</button>
|
||||
<button class="an-btn" type="button" id="an-dlg-close" autofocus>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="an-tip" role="tooltip"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -36,14 +36,29 @@
|
||||
{{define "sidebar_nav"}}
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-chrome"><i></i><i></i><i></i></span>
|
||||
<span class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</span>
|
||||
<span class="brand-name">Cloud IP Validator<small>admin</small></span>
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups">
|
||||
<div class="sidebar-session">
|
||||
{{$down := and .Banner.Message (not .Banner.Client)}}
|
||||
<div class="session-row">
|
||||
<span class="session-api" title="{{if $down}}Нет связи с control-api: {{.Banner.Message}}{{else}}Связь с control-api в порядке{{end}}"><span class="pulse-dot{{if $down}} down{{end}}"></span>control-api{{if $down}} · нет связи{{end}}</span>
|
||||
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{{if .AuthEnabled}}<form class="session-row" method="post" action="/logout">
|
||||
<span class="session-user" title="{{.User}}">{{.User}}</span>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups" aria-label="Разделы">
|
||||
<div class="nav-group-title">Мониторинг</div>
|
||||
<a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
|
||||
Обзор
|
||||
@@ -56,6 +71,11 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg>
|
||||
Реестр
|
||||
</a>
|
||||
<a class="nav-link{{if eq .ActiveNav "analytics"}} active{{end}}" {{if eq .ActiveNav "analytics"}}aria-current="page"{{end}} href="/analytics">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
|
||||
Аналитика
|
||||
</a>
|
||||
<div class="nav-group-title">Настройка</div>
|
||||
<a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="7" rx="1.5"/><rect x="4" y="14" width="16" height="7" rx="1.5"/><circle cx="8" cy="6.5" r="1"/><circle cx="8" cy="17.5" r="1"/></svg>
|
||||
Валидаторы
|
||||
@@ -78,20 +98,6 @@
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{{if .AuthEnabled}}
|
||||
<form class="sidebar-user" method="post" action="/logout">
|
||||
<span class="sidebar-user-name" title="{{.User}}">{{.User}}</span>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
|
||||
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
Глубина хранимой истории на адрес настраивается на <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>
|
||||
|
||||
@@ -117,4 +117,23 @@ inbound-проверки к нулю, не трогая список площа
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<p class="muted" style="margin-bottom:16px">Подсети для страницы <a href="/analytics">«Аналитика»</a>: по ним
|
||||
группируются адреса запуска. По одной в строке — <code>CIDR</code> и, через пробел, необязательная подпись
|
||||
(<code>83.166.248.0/21 москва</code>). Адрес относится к самой узкой подходящей подсети, остальные идут в строку
|
||||
«прочие». Пока список пуст, адреса группируются по /24.</p>
|
||||
<form hx-put="/settings/subnets" hx-target="#settings-form-wrap" hx-swap="innerHTML">
|
||||
<div class="field">
|
||||
<label for="subnets_text">Подсети</label>
|
||||
<textarea id="subnets_text" name="subnets" rows="8" spellcheck="false" placeholder="83.166.248.0/21 212.233.72.0/21 подпись">{{range .Subnets.Subnets}}{{.CIDR}}{{if .Label}} {{.Label}}{{end}}
|
||||
{{end}}</textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in new issue
Block a user