Files
nmon/internal/api/ops.go

180 lines
7.2 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/store"
)
// TableView says how one discovery table went.
type TableView struct {
Name string `json:"name"`
Status string `json:"status" doc:"ok, partial (row limit or timeout: previous rows kept), error or skipped."`
Rows int `json:"rows"`
Detail string `json:"detail,omitempty"`
}
// DiscoverView summarises one discovery run.
type DiscoverView struct {
Profile string `json:"profile"`
ProfileMissing string `json:"profile_missing,omitempty" doc:"The profile the device asked for when it no longer exists; generic was used."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
Partial bool `json:"partial" doc:"A table overran its row limit or timeout; its previous result was kept."`
Tables []TableView `json:"tables"`
InterfacesMatched int `json:"interfaces_matched"`
InterfacesNew int `json:"interfaces_new"`
InterfacesRetired int `json:"interfaces_retired"`
InterfacesSplit int `json:"interfaces_split" doc:"Interfaces that could not be matched one-to-one; both rows are kept and an identity review is open."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
Unretired int `json:"interfaces_unretired"`
IdentityReviews int `json:"identity_reviews_opened"`
HardwareReplaced bool `json:"hardware_replaced"`
Warnings []string `json:"warnings"`
}
// PollerDevice is the poller's view of one device. It uses the slug, never the numeric ID.
type PollerDevice struct {
Device string `json:"device" doc:"Device slug."`
State string `json:"state" doc:"ok or degraded."`
Profile string `json:"profile"`
Interfaces int `json:"interfaces" doc:"Interfaces polled."`
VarbindsPerRequest int `json:"varbinds_per_request"`
ConsecutiveFailures int `json:"consecutive_failures"`
MissedDeadlines int `json:"missed_deadlines"`
IntervalSeconds float64 `json:"interval_seconds" doc:"Current cadence of the hot group, backoff included."`
BaseIntervalSeconds float64 `json:"base_interval_seconds"`
LastCycleSeconds float64 `json:"last_cycle_duration_seconds"`
LastCycle *time.Time `json:"last_cycle,omitempty"`
NextCycle *time.Time `json:"next_cycle,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// IdentityReviewView is an open identity review.
type IdentityReviewView struct {
ID int64 `json:"id"`
Device string `json:"device,omitempty" doc:"Device slug."`
Kind string `json:"kind" doc:"ambiguous_match, hardware_replaced or reattach_candidate."`
Candidates json.RawMessage `json:"candidates"`
Evidence json.RawMessage `json:"evidence"`
CreatedAt time.Time `json:"created_at"`
}
func (s *server) registerOps(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "discover-device", Method: "POST", Path: "/api/v1/devices/{slug}/discover", Tags: []string{"devices"},
Summary: "Run discovery now",
Description: "Walks the device synchronously (bounded by the discovery timeout), applies the identity matcher and " +
"returns what changed. Admin-only once authentication arrives (M6).",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusBadGateway},
}, s.discoverDevice)
huma.Register(api, huma.Operation{
OperationID: "poller-health", Method: "GET", Path: "/api/v1/poller/health", Tags: []string{"poller"},
Summary: "Poller state of every polled device",
}, s.pollerHealth)
huma.Register(api, huma.Operation{
OperationID: "list-identity-reviews", Method: "GET", Path: "/api/v1/identity-reviews", Tags: []string{"devices"},
Summary: "Open identity reviews",
}, s.identityReviews)
}
func (s *server) discoverDevice(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
}) (*struct{ Body DiscoverView }, error) {
dev, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
if err != nil {
return nil, s.storeErr(err)
}
if dev.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
if dev.RetiredAt != nil || dev.Kind != store.KindNetwork || dev.SNMPCredentialID == nil {
return nil, huma.Error409Conflict("device is retired or has no SNMP credential; nothing to discover")
}
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
defer cancel()
deps := discovery.Deps{Store: s.Store, Profiles: s.Profiles, Clock: s.Clock, SNMPPort: s.SNMPPort, Logger: s.Logger}
rep, err := discovery.Discover(dctx, deps, dev)
if err != nil {
return nil, huma.Error502BadGateway("discovery of " + in.Slug + " failed: " + err.Error())
}
if s.AfterDiscover != nil {
s.AfterDiscover(dev.ID)
}
return &struct{ Body DiscoverView }{DiscoverViewOf(rep)}, nil
}
// DiscoverViewOf converts a discovery report.
func DiscoverViewOf(r *discovery.Report) DiscoverView {
v := DiscoverView{
Profile: r.Profile, ProfileMissing: r.ProfileMissing, Partial: r.Partial, Tables: []TableView{},
InterfacesMatched: r.Matched, InterfacesNew: r.New, InterfacesRetired: r.Retired, InterfacesSplit: r.Split,
Unretired: r.Unretired, IdentityReviews: len(r.Reviews), HardwareReplaced: r.HardwareReplaced,
Warnings: nonNil(r.Warnings),
}
for _, t := range r.Tables {
v.Tables = append(v.Tables, TableView{Name: t.Name, Status: t.Status, Rows: t.Rows, Detail: t.Detail})
}
return v
}
func (s *server) pollerHealth(context.Context, *struct{}) (*struct{ Body []PollerDevice }, error) {
out := []PollerDevice{}
if s.PollerStatus != nil {
for _, d := range s.PollerStatus() {
out = append(out, pollerDevice(d))
}
}
return &struct{ Body []PollerDevice }{out}, nil
}
func pollerDevice(d poller.DeviceStatus) PollerDevice {
return PollerDevice{
Device: d.Device, State: d.State, Profile: d.Profile, Interfaces: d.Interfaces, VarbindsPerRequest: d.Varbinds,
ConsecutiveFailures: d.ConsecutiveFailures, MissedDeadlines: d.MissedDeadlines,
IntervalSeconds: d.Interval.Seconds(), BaseIntervalSeconds: d.BaseInterval.Seconds(),
LastCycleSeconds: d.LastCycleDuration.Seconds(), LastCycle: tp(d.LastCycle), NextCycle: tp(d.NextCycle),
LastError: d.LastError,
}
}
func tp(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
func (s *server) identityReviews(ctx context.Context, _ *struct{}) (*struct{ Body []IdentityReviewView }, error) {
rows, _, err := s.Store.ListOpenIdentityReviews(ctx, store.Page{Limit: store.MaxLimit})
if err != nil {
return nil, s.storeErr(err)
}
out := []IdentityReviewView{}
for _, r := range rows {
v := IdentityReviewView{
ID: r.ID, Kind: r.Kind, Candidates: rawJSON(r.Candidates), Evidence: rawJSON(r.Evidence), CreatedAt: r.CreatedAt.UTC(),
}
if r.DeviceID != nil {
if d, err := s.Store.GetDeviceByID(ctx, *r.DeviceID); err == nil {
v.Device = d.Slug
}
}
out = append(out, v)
}
return &struct{ Body []IdentityReviewView }{out}, nil
}
func rawJSON(s string) json.RawMessage {
if json.Valid([]byte(s)) {
return json.RawMessage(s)
}
b, _ := json.Marshal(s)
return b
}