Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
180 lines
7.2 KiB
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
|
|
}
|