Files
nmon/internal/api/profiles.go
jleung 345daaa773 Profiles for EdgeSwitch, UniFi switch/AP and VyOS; profile pinning and re-match; whole-row navigation
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
2026-10-23 05:03:17 +00:00

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)
}
}