Discovery re-matches unpinned devices every run and at startup; an admin can pin a
profile (PUT /devices/{slug} profile, GET /profiles, DeviceView profile_pinned and
profile_match). Rows on the device list, interface search and poller health navigate on
click and Enter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
144 lines
5.0 KiB
Go
144 lines
5.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/discovery"
|
|
"github.com/v10networks/nmon/internal/profile"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// ProfileMatchRules are a profile's own match rules (they are not inherited through extends).
|
|
type ProfileMatchRules struct {
|
|
SysObjectID []string `json:"sysObjectID" doc:"sysObjectID prefixes, matched on a dot boundary."`
|
|
SysDescr []string `json:"sysDescr" doc:"Regular expressions tried against sysDescr."`
|
|
}
|
|
|
|
// ProfileView is one profile as GET /profiles lists it.
|
|
type ProfileView struct {
|
|
Name string `json:"name"`
|
|
Priority int `json:"priority" doc:"The highest priority wins when several profiles match a device."`
|
|
Extends string `json:"extends,omitempty"`
|
|
Source string `json:"source" enum:"shipped,operator" doc:"operator: the file comes from the operator's profiles directory."`
|
|
Match ProfileMatchRules `json:"match"`
|
|
}
|
|
|
|
// ProfileMatchView says how a device's profile was chosen.
|
|
type ProfileMatchView struct {
|
|
MatchedBy string `json:"matched_by" enum:"sysObjectID,sysDescr,default,pinned" doc:"The rule that picks it."`
|
|
GenericReason string `json:"generic_reason,omitempty" doc:"Why no vendor profile applies, when the profile is generic."`
|
|
}
|
|
|
|
func (s *server) registerProfiles(api huma.API) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "list-profiles", Method: http.MethodGet, Path: "/api/v1/profiles", Tags: []string{"profiles"},
|
|
Summary: "List the loaded device profiles with their match rules",
|
|
}, s.listProfiles)
|
|
}
|
|
|
|
func (s *server) listProfiles(context.Context, *struct{}) (*struct{ Body []ProfileView }, error) {
|
|
out := []ProfileView{}
|
|
if s.Profiles != nil {
|
|
for _, n := range s.Profiles.Names() {
|
|
r, _ := s.Profiles.Lookup(n)
|
|
v := ProfileView{
|
|
Name: r.Name, Priority: r.Priority, Source: "shipped",
|
|
Match: ProfileMatchRules{SysObjectID: nonNil(r.Match.SysObjectID), SysDescr: nonNil(r.Match.SysDescr)},
|
|
}
|
|
if r.Operator {
|
|
v.Source = "operator"
|
|
}
|
|
if len(r.Chain) > 1 {
|
|
v.Extends = r.Chain[1]
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return &struct{ Body []ProfileView }{out}, nil
|
|
}
|
|
|
|
// profileMatch explains the device's profile choice; nil when no profiles are loaded.
|
|
func (s *server) profileMatch(d *store.Device) *ProfileMatchView {
|
|
if d.ProfilePinned {
|
|
return &ProfileMatchView{MatchedBy: "pinned"}
|
|
}
|
|
if s.Profiles == nil {
|
|
return nil
|
|
}
|
|
_, by := s.Profiles.MatchWhy(d.SysObjectID, d.SysDescr)
|
|
v := &ProfileMatchView{MatchedBy: string(by)}
|
|
if by == profile.ByDefault {
|
|
if d.SysObjectID == "" {
|
|
v.GenericReason = "not discovered yet: no sysObjectID to match"
|
|
} else {
|
|
v.GenericReason = fmt.Sprintf("no vendor profile matched sysObjectID %s or sysDescr", d.SysObjectID)
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// planProfile applies the body's profile to the plan: a name pins it, "" returns the device to automatic
|
|
// matching (the profile is re-matched from the stored sysObjectID/sysDescr now and again at each discovery).
|
|
func (s *server) planProfile(p *editPlan, dev *store.Device, name string) error {
|
|
if name == "" {
|
|
p.after.ProfilePinned, p.after.Profile = false, s.autoProfile(dev)
|
|
p.change["profile"] = "automatic"
|
|
} else {
|
|
if err := s.knownProfile(name); err != nil {
|
|
return err
|
|
}
|
|
p.after.Profile, p.after.ProfilePinned = name, true
|
|
p.change["profile"] = name
|
|
}
|
|
p.rediscover = p.after.Profile != dev.Profile || p.after.ProfilePinned != dev.ProfilePinned
|
|
if !p.rediscover {
|
|
delete(p.change, "profile")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) knownProfile(name string) error {
|
|
if s.Profiles == nil {
|
|
return nil
|
|
}
|
|
if _, ok := s.Profiles.Lookup(name); !ok {
|
|
return huma.Error422UnprocessableEntity("unknown profile " + name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// autoProfile is the profile Match picks for the device's stored sysObjectID and sysDescr, or its current one
|
|
// when nothing was discovered yet.
|
|
func (s *server) autoProfile(dev *store.Device) string {
|
|
if s.Profiles == nil || (dev.SysObjectID == "" && dev.SysDescr == "") {
|
|
return dev.Profile
|
|
}
|
|
if m := s.Profiles.Match(dev.SysObjectID, dev.SysDescr); m != nil {
|
|
return m.Name
|
|
}
|
|
return dev.Profile
|
|
}
|
|
|
|
// rediscoverAfterEdit runs one discovery so the new profile's tables are read at once. A device that cannot be
|
|
// reached keeps its edit; the next scheduled run applies it.
|
|
func (s *server) rediscoverAfterEdit(ctx context.Context, slug string) {
|
|
dev, err := s.Store.GetDeviceBySlug(ctx, slug)
|
|
if err != nil || dev.RetiredAt != nil || dev.Kind != store.KindNetwork || dev.SNMPCredentialID == nil {
|
|
return
|
|
}
|
|
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}
|
|
if _, err := discovery.Discover(dctx, deps, dev); err != nil {
|
|
s.Logger.Warn("discovery after profile change failed", "device", slug, "err", err)
|
|
return
|
|
}
|
|
if s.AfterDiscover != nil {
|
|
s.AfterDiscover(dev.ID)
|
|
}
|
|
}
|