Files
cloud-ip-validator/internal/dashboard/client.go
T
ayurishchevandClaude Sonnet 5.5 068c10ea1c Analytics: compare two finished runs
New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.

Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:26:37 +03:00

551 lines
19 KiB
Go

package dashboard
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// apiErr is returned by every client method for a non-2xx response from
// control-api, or for a transport-level failure (control-api unreachable/
// timeout, Status==0). Handlers use it (via errors.As) to render an error
// banner with the right status/severity instead of a raw 500 — see
// writeErrorBanner in render.go.
type apiErr struct {
Status int
Message string
}
func (e *apiErr) Error() string {
if e.Status == 0 {
return fmt.Sprintf("control-api недоступен: %s", e.Message)
}
return fmt.Sprintf("control-api: %d %s", e.Status, e.Message)
}
// client is a thin, dashboard-local JSON client for control-api's
// /api/v1/admin/* surface. It intentionally doesn't reuse
// internal/apiclient.Client (shared by validator-agent/prober): that
// client's Do only returns an opaque error, with no way to recover the
// HTTP status code — which the dashboard needs to render 400 vs 404 vs 409
// vs 5xx differently. Duplicating ~30 lines here avoids widening
// apiclient's contract (and its blast radius on the other two binaries)
// for a need only this package has.
type client struct {
baseURL string
http *http.Client
// long is used for clear / bulk operations, which legitimately take far
// longer than a plain read (thousands of rows in one transaction): same
// transport, but a longer whole-call timeout.
long *http.Client
// token, when non-empty, is sent to control-api as a Bearer credential.
token string
}
// longCallTimeout is the minimum whole-call timeout for clear and bulk
// operations (ClearQueue, DeleteIPs, SubmitIPs).
const longCallTimeout = 120 * time.Second
func newClient(baseURL string, timeout time.Duration) *client {
longT := longCallTimeout
if timeout > longT {
longT = timeout
}
return &client{
baseURL: baseURL,
http: &http.Client{Timeout: timeout},
long: &http.Client{Timeout: longT},
}
}
func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error {
return c.doWith(ctx, c.http, method, path, body, out)
}
// doLong is do with the long (clear/bulk) timeout.
func (c *client) doLong(ctx context.Context, method, path string, body, out interface{}) error {
hc := c.long
if hc == nil {
hc = c.http
}
return c.doWith(ctx, hc, method, path, body, out)
}
func (c *client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out interface{}) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := hc.Do(req)
if err != nil {
return &apiErr{Status: 0, Message: err.Error()}
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
msg := string(respBody)
var er errorResponse
if json.Unmarshal(respBody, &er) == nil && er.Error != "" {
msg = er.Error
}
return &apiErr{Status: resp.StatusCode, Message: msg}
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decode response from %s %s: %w", method, path, err)
}
}
return nil
}
func (c *client) Status(ctx context.Context) (statusResponse, error) {
var out statusResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/status", nil, &out)
return out, err
}
// maxPageLimit is control-api's cap on `limit`.
const maxPageLimit = 1000
// clampLimit keeps limit within 1..maxPageLimit: a request without `limit`
// would make control-api answer with the legacy unbounded bare array.
func clampLimit(limit int) int {
if limit < 1 {
return 1
}
if limit > maxPageLimit {
return maxPageLimit
}
return limit
}
// ipsQuery selects one page of GET /admin/ips: server-side filters plus
// limit/offset. Order is "sequence" (default) or "aggregated_at_desc".
type ipsQuery struct {
States []string
Q string
Result string
Order string
Limit int
Offset int
}
func (q ipsQuery) values() url.Values {
v := url.Values{}
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
if len(q.States) > 0 {
v.Set("state", strings.Join(q.States, ","))
}
if q.Q != "" {
v.Set("q", q.Q)
}
if q.Result != "" {
v.Set("result", q.Result)
}
if q.Order != "" {
v.Set("order", q.Order)
}
return v
}
// ListIPsPage returns one page of the check queue plus the total number of
// rows matching the filter. Never loads the whole queue.
func (c *client) ListIPsPage(ctx context.Context, q ipsQuery) (ipsPage, error) {
var out ipsPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips?"+q.values().Encode(), nil, &out)
return out, err
}
func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error) {
var out ipDetailResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, &out)
return out, err
}
// SubmitIPs is the single entry point for both adding new addresses and
// forcing a recheck of already-finished ones — see docs/API.md.
func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) {
var out submitIPsResponse
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
return out, err
}
func (c *client) CancelIP(ctx context.Context, ip string) error {
return c.do(ctx, http.MethodPost, "/api/v1/admin/ips/"+url.PathEscape(ip)+"/cancel", nil, nil)
}
// DeleteIP permanently removes one address and its full history — unlike
// CancelIP, there's nothing left to look up afterward.
func (c *client) DeleteIP(ctx context.Context, ip string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, nil)
}
// DeleteIPs permanently removes a specific list of addresses in one call.
func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsResponse, error) {
var out deleteIPsResponse
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
return out, err
}
// ClearQueue permanently removes every address currently in the queue,
// including those actively being checked.
func (c *client) ClearQueue(ctx context.Context) (clearQueueResponse, error) {
var out clearQueueResponse
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
return out, err
}
// StartScan starts control-api's background floating-IP scan (or joins the
// one already running) and returns immediately with its current status.
func (c *client) StartScan(ctx context.Context, dryRun bool) (scanStatusDTO, error) {
var out scanStatusDTO
path := "/api/v1/admin/ips/scan"
if dryRun {
path += "?dry_run=true"
}
err := c.do(ctx, http.MethodPost, path, nil, &out)
return out, err
}
// ScanStatus returns the progress of the background scan job.
func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
var out scanStatusDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/scan", nil, &out)
return out, err
}
// registryQuery selects one page of GET /admin/registry.
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
}
// ListRegistryPage returns one page of the registry (every address ever
// submitted to the check queue, each with a summary of its accumulated check
// history — survives an address being deleted from the queue and later
// re-added) plus the total number of rows matching the filter.
func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) {
v := url.Values{}
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
if q.Q != "" {
v.Set("q", q.Q)
}
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
}
// GetRegistryHistory returns one address's registry record plus its full
// retained check history across every cycle still kept.
func (c *client) GetRegistryHistory(ctx context.Context, ip string) (registryHistoryResponse, error) {
var out registryHistoryResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry/"+url.PathEscape(ip), nil, &out)
return out, err
}
func (c *client) ListValidators(ctx context.Context) ([]validatorDTO, error) {
var out []validatorDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/validators", nil, &out)
return out, err
}
func (c *client) CreateValidator(ctx context.Context, id, osPortID string) error {
body := map[string]string{"validator_id": id, "os_port_id": osPortID}
return c.do(ctx, http.MethodPost, "/api/v1/admin/config/validators", body, nil)
}
func (c *client) UpdateValidator(ctx context.Context, id, osPortID string) error {
body := map[string]string{"os_port_id": osPortID}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/validators/"+url.PathEscape(id), body, nil)
}
func (c *client) DeleteValidator(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/validators/"+url.PathEscape(id), nil, nil)
}
func (c *client) ListSites(ctx context.Context) ([]siteDTO, error) {
var out []siteDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/sites", nil, &out)
return out, err
}
func (c *client) PutSite(ctx context.Context, index int, siteID string) error {
body := map[string]string{"site_id": siteID}
return c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), body, nil)
}
func (c *client) DeleteSite(ctx context.Context, index int) error {
return c.do(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), nil, nil)
}
func (c *client) ListTargetGroups(ctx context.Context) ([]targetGroupDTO, error) {
var out []targetGroupDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/targets", nil, &out)
return out, err
}
func (c *client) PutTargetGroup(ctx context.Context, name string, targets []string) error {
body := map[string][]string{"targets": targets}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/targets/"+url.PathEscape(name), body, nil)
}
func (c *client) DeleteTargetGroup(ctx context.Context, name string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/targets/"+url.PathEscape(name), nil, nil)
}
func (c *client) ListCheckTypes(ctx context.Context) ([]checkTypeDTO, error) {
var out []checkTypeDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/check-types", nil, &out)
return out, err
}
func (c *client) PutCheckType(ctx context.Context, name string, enabled bool, targets []string) error {
body := map[string]interface{}{"enabled": enabled, "targets": targets}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/check-types/"+url.PathEscape(name), body, nil)
}
func (c *client) DeleteCheckType(ctx context.Context, name string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/check-types/"+url.PathEscape(name), nil, nil)
}
func (c *client) GetOrchestratorSettings(ctx context.Context) (orchestratorSettingsDTO, error) {
var out orchestratorSettingsDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/orchestrator", nil, &out)
return out, err
}
func (c *client) PutOrchestratorSettings(ctx context.Context, fipSettleSeconds, historyRetentionCycles, selfCheckMaxAttempts int) (orchestratorSettingsDTO, error) {
var out orchestratorSettingsDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/orchestrator",
orchestratorSettingsDTO{FIPSettleSeconds: fipSettleSeconds, HistoryRetentionCycles: historyRetentionCycles, SelfCheckMaxAttempts: selfCheckMaxAttempts}, &out)
return out, err
}
func (c *client) GetInboundChecks(ctx context.Context) (inboundChecksDTO, error) {
var out inboundChecksDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/inbound-checks", nil, &out)
return out, err
}
func (c *client) PutInboundChecks(ctx context.Context, ports []int, icmp bool) (inboundChecksDTO, error) {
var out inboundChecksDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/inbound-checks",
inboundChecksDTO{Ports: ports, ICMP: icmp}, &out)
return out, err
}
func (c *client) GetAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/auto-cycle", nil, &out)
return out, err
}
func (c *client) PutAutoCycle(ctx context.Context, intervalSeconds, maxRunSeconds int) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/auto-cycle",
map[string]int{"interval_seconds": intervalSeconds, "max_run_seconds": maxRunSeconds}, &out)
return out, err
}
func (c *client) StartAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/start", nil, &out)
return out, err
}
func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
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) {
return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
}
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, 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
}
// compareFilter narrows one list of the comparison of two runs.
type compareFilter struct{ Indicator, From, To string }
func analyticsCompareQuery(base, target int64) url.Values {
return url.Values{"base": {strconv.FormatInt(base, 10)}, "target": {strconv.FormatInt(target, 10)}}
}
func analyticsCompareListPath(base, target int64, group string, f compareFilter, csv bool) string {
v := analyticsCompareQuery(base, target)
if f.Indicator != "" {
v.Set("indicator", f.Indicator)
}
if f.From != "" || f.To != "" {
v.Set("from", f.From)
v.Set("to", f.To)
}
if csv {
v.Set("format", "csv")
}
return "/api/v1/admin/analytics/compare/lists/" + url.PathEscape(group) + "?" + v.Encode()
}
// GetAnalyticsCompare returns the comparison of two finished runs (base is the
// older one) as the raw JSON control-api computed.
func (c *client) GetAnalyticsCompare(ctx context.Context, base, target int64) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/compare?"+analyticsCompareQuery(base, target).Encode(), nil, &out)
return out, err
}
// GetAnalyticsCompareList returns one address table (JSON) of the comparison.
func (c *client) GetAnalyticsCompareList(ctx context.Context, base, target int64, group string, f compareFilter) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, analyticsCompareListPath(base, target, group, f, false), nil, &out)
return out, err
}
// GetAnalyticsCompareListCSV returns the CSV file of one comparison table,
// with the file name control-api proposed.
func (c *client) GetAnalyticsCompareListCSV(ctx context.Context, base, target int64, group string, f compareFilter) ([]byte, string, error) {
return c.getCSV(ctx, analyticsCompareListPath(base, target, group, f, true))
}
// 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
}