package httpapi import ( "bytes" "encoding/csv" "fmt" "net/http" "regexp" "strconv" "strings" "sync" "time" "cloudipvalidator/internal/analytics" "cloudipvalidator/internal/db" ) // analyticsRunDTO is one entry of the run selector. type analyticsRunDTO 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 is the number of queue rows of the run, Pending those still being // processed (only an open run has any). Total int `json:"total"` Pending int `json:"pending"` } type subnetDTO struct { CIDR string `json:"cidr"` Label string `json:"label,omitempty"` } type subnetsDTO struct { Subnets []subnetDTO `json:"subnets"` } // analyticsCache keeps the computed analysis of finalized runs. An entry is // valid while the run's data version (checks written, results) is unchanged; // the subnet list is part of the key because it changes the grouping. type analyticsCache struct { mu sync.Mutex entries map[int64]analyticsEntry } type analyticsEntry struct { version string an *analytics.Analysis } func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) { runs, err := s.DB.ListRuns(r.Context()) if err != nil { writeDBError(w, err) return } out := make([]analyticsRunDTO, 0, len(runs)) for _, x := range runs { out = append(out, analyticsRunDTO{ ID: x.ID, Kind: x.Kind, State: x.State, StartedAt: x.StartedAt, FinalizedAt: x.FinalizedAt, Addresses: x.Addresses, Pass: x.Pass, Partial: x.Partial, Fail: x.Fail, Cancelled: x.Cancelled, Total: x.Total, Pending: x.Pending, }) } writeJSON(w, http.StatusOK, out) } // analysisFor returns the analysis of a finalized run, from the cache when the // run's data has not changed since it was computed. It writes the error // response itself and returns nil when it cannot. func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "invalid run id") return nil } ctx := r.Context() run, err := s.DB.GetRun(ctx, id) if err != nil { writeDBError(w, err) return nil } if run.State != db.RunFinalized { writeError(w, http.StatusConflict, "run is still open: analytics are available for finished runs") return nil } data, err := s.DB.RunDataVersion(ctx, id) if err != nil { writeDBError(w, err) return nil } subnets, err := s.DB.ListSubnets(ctx) if err != nil { writeDBError(w, err) return nil } var sb strings.Builder for _, x := range subnets { sb.WriteString(x.CIDR + "|" + x.Label + ";") } version := data + "#" + sb.String() s.analytics.mu.Lock() defer s.analytics.mu.Unlock() if e, ok := s.analytics.entries[id]; ok && e.version == version { return e.an } an, err := analytics.Load(ctx, s.DB, id) if err != nil { writeDBError(w, err) return nil } if s.analytics.entries == nil { s.analytics.entries = map[int64]analyticsEntry{} } s.analytics.entries[id] = analyticsEntry{version: version, an: an} return an } func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) { if an := s.analysisFor(w, r); an != nil { writeJSON(w, http.StatusOK, an.Report) } } var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) // handleAnalyticsList serves the address table behind one indicator // (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all), // the addresses of one verdict (kind = verdict_pass|verdict_partial|verdict_fail) // or one ingress error class (kind = error, ?class=...), as JSON or, with // ?format=csv, as a downloadable CSV file. func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) { an := s.analysisFor(w, r) if an == nil { return } kind, class := r.PathValue("kind"), r.URL.Query().Get("class") list, err := an.List(kind, class) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } if r.URL.Query().Get("format") != "csv" { writeJSON(w, http.StatusOK, list) return } var buf bytes.Buffer buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM, so Excel opens the file as UTF-8 cw := csv.NewWriter(&buf) cw.UseCRLF = true _ = cw.Write(list.Columns) _ = cw.WriteAll(list.Rows) name := kind if kind == analytics.ListError { if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" { name += "-" + slug } else { name += "-class" } } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s_run%s.csv"`, name, r.PathValue("id"))) w.WriteHeader(http.StatusOK) _, _ = w.Write(buf.Bytes()) } func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) { list, err := s.DB.ListSubnets(r.Context()) if err != nil { writeDBError(w, err) return } out := subnetsDTO{Subnets: make([]subnetDTO, 0, len(list))} for _, x := range list { out.Subnets = append(out.Subnets, subnetDTO{CIDR: x.CIDR, Label: x.Label}) } writeJSON(w, http.StatusOK, out) } // handleConfigPutSubnets replaces the whole subnet list. The list groups the // addresses on the analytics page; with none configured they group by /24. func (s *Server) handleConfigPutSubnets(w http.ResponseWriter, r *http.Request) { var req subnetsDTO if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body: "+err.Error()) return } in := make([]db.Subnet, 0, len(req.Subnets)) for _, x := range req.Subnets { in = append(in, db.Subnet{CIDR: strings.TrimSpace(x.CIDR), Label: x.Label}) } if err := s.DB.ReplaceSubnets(r.Context(), in); err != nil { writeDBError(w, err) return } s.handleConfigGetSubnets(w, r) }