Make nmon easier to install and run without editing YAML by hand: - Settings: point-and-click forms over config.yaml (GET/PUT /api/v1/settings/form); the YAML editor moves to /settings/yaml. - First install: with no sign-in configured nmon starts in install mode, serves only the setup wizard (sign-in, basics, devices, alerting) and closes it for good once sign-in is saved. A self-signed certificate is generated when no TLS source is given. poller.interval becomes a config setting. - Reverse proxies: opt-in --http-listen plain-HTTP listener beside HTTPS; sign-in stays required. - Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets, template preview and test send. - Help: the operations, coverage and profile docs served at /help. - Polling: per-device "Poll now", a dry-run test poll returning an SNMP trace, and time-limited in-memory SNMP debug recording, in the UI and as `nmon device poll`. - Ansible: deploys into install mode when no sign-in is set, and no longer overwrites the config.yaml the wizard wrote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
229 lines
10 KiB
Go
229 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// DeviceRef names a device in another object. Devices are addressed by slug.
|
|
type DeviceRef struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// GroupView is a group an interface of the device belongs to.
|
|
type GroupView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind" enum:"site,customer,role,custom"`
|
|
}
|
|
|
|
// InterfaceView is an interface as the API shows it, addressed by ULID. The
|
|
// rate fields are five-minute rates from the hot store, null when unknown or
|
|
// when the interface is unreliable (its counters cannot be trusted).
|
|
type InterfaceView struct {
|
|
ID string `json:"id" doc:"Immutable ULID."`
|
|
IfIndex int64 `json:"ifindex" doc:"Current ifIndex; may change, the ID does not."`
|
|
IfName string `json:"ifname"`
|
|
IfDescr string `json:"ifdescr"`
|
|
IfAlias string `json:"ifalias"`
|
|
Description string `json:"description" doc:"Operator-owned port description; discovery never overwrites it."`
|
|
IfPhysAddr string `json:"ifphysaddr"`
|
|
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
|
|
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
|
|
IfType int64 `json:"iftype"`
|
|
AdminStatus int64 `json:"admin_status" doc:"ifAdminStatus: 1 up, 2 down, 3 testing"`
|
|
OperStatus int64 `json:"oper_status" doc:"ifOperStatus: 1 up, 2 down, 3 testing, 4 unknown, 5 dormant, 6 notPresent, 7 lowerLayerDown"` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
|
HasHCCounters bool `json:"has_hc_counters"`
|
|
Excluded bool `json:"excluded"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
LastDiscovered time.Time `json:"last_discovered"`
|
|
Unreliable bool `json:"unreliable" doc:"Counters cannot be trusted (32-bit counters on a fast link); no rate is reported."`
|
|
RetiredAt *time.Time `json:"retired_at" nullable:"true"`
|
|
InBPS *float64 `json:"in_bps" nullable:"true" doc:"Inbound bits per second over the live window (two default poll intervals)."`
|
|
OutBPS *float64 `json:"out_bps" nullable:"true"`
|
|
InErrorsPS *float64 `json:"in_errors_ps" nullable:"true" doc:"Inbound errors per second over the live window."`
|
|
OutErrorsPS *float64 `json:"out_errors_ps" nullable:"true"`
|
|
AlertDownState
|
|
}
|
|
|
|
// DeviceView is a device as the API shows it. It carries no numeric ID and no credential.
|
|
type DeviceView struct {
|
|
Slug string `json:"slug" doc:"Immutable public key."`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Site string `json:"site"`
|
|
Role string `json:"role"`
|
|
Profile string `json:"profile"`
|
|
ProfilePinned bool `json:"profile_pinned" doc:"An operator chose the profile; discovery keeps it."`
|
|
ProfileMatch *ProfileMatchView `json:"profile_match,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
// PollIntervalS is the admin's SNMP poll interval override in seconds (0 = the default);
|
|
// PollIntervalEffectiveS is the interval in force.
|
|
PollIntervalS int `json:"poll_interval_s" doc:"Poll interval override in seconds; 0 means the default."`
|
|
PollIntervalEffectiveS int `json:"poll_interval_effective_s" doc:"The SNMP poll interval in force, in seconds."`
|
|
MgmtIP string `json:"mgmt_ip"`
|
|
Addresses []string `json:"addresses"`
|
|
SNMPVersion string `json:"snmp_version,omitempty"`
|
|
SysName string `json:"sys_name"`
|
|
SysObjectID string `json:"sys_object_id"`
|
|
SysDescr string `json:"sys_descr"`
|
|
HWSerial string `json:"hw_serial"`
|
|
UptimeSeconds *float64 `json:"uptime_seconds" nullable:"true" doc:"sysUpTime from the hot store; null when unknown."`
|
|
Reachability string `json:"reachability" enum:"up,down,warning,unknown,maintenance"`
|
|
PollState string `json:"poll_state" enum:"ok,degraded,backoff"`
|
|
Warnings []string `json:"warnings"`
|
|
LastSeen *time.Time `json:"last_seen" nullable:"true"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
RetiredAt *time.Time `json:"retired_at,omitempty" nullable:"true"`
|
|
Parents []DeviceRef `json:"parents"`
|
|
Groups []GroupView `json:"groups"`
|
|
Interfaces []InterfaceView `json:"interfaces"`
|
|
// M5 additions.
|
|
HostedOn *DeviceRef `json:"hosted_on,omitempty" doc:"The host node of a VM or container."`
|
|
Guests []DeviceRef `json:"guests" doc:"VMs and containers hosted on this device."`
|
|
Checks []CheckView `json:"checks" doc:"Service checks whose results belong to this device."`
|
|
PushedSeries PushedSeries `json:"pushed_series" doc:"Series push senders wrote for this device in the last hour."`
|
|
}
|
|
|
|
type deviceOut struct{ Body DeviceView }
|
|
|
|
func (s *server) registerDeviceViews(api huma.API) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-device", Method: http.MethodGet, Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
|
|
Summary: "Get a device, its interfaces with live rates, parents and groups",
|
|
Description: "Rates are one batched query to the hot store; they are null when the store is unavailable.",
|
|
Errors: []int{http.StatusNotFound},
|
|
}, s.getDevice)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "put-device", Method: http.MethodPut, Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
|
|
Summary: "Edit a device",
|
|
Description: "Only the fields present change. parents replaces the parent list (by slug) and is rejected with 422 " +
|
|
"naming the path when it would make a cycle. snmp replaces the credential.",
|
|
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity},
|
|
}, s.putDevice)
|
|
}
|
|
|
|
func (s *server) getDevice(ctx context.Context, in *struct {
|
|
Slug string `path:"slug" doc:"Device slug."`
|
|
}) (*deviceOut, error) {
|
|
return s.view(ctx, in.Slug)
|
|
}
|
|
|
|
// view loads the device and everything shown with it.
|
|
func (s *server) view(ctx context.Context, slug string) (*deviceOut, error) {
|
|
d, err := s.Store.GetDeviceBySlug(ctx, slug)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
if d.PurgedAt != nil {
|
|
return nil, huma.Error404NotFound("not found")
|
|
}
|
|
v := DeviceView{
|
|
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, Profile: d.Profile,
|
|
ProfilePinned: d.ProfilePinned, ProfileMatch: s.profileMatch(d), Enabled: d.Enabled,
|
|
PollIntervalS: d.PollIntervalS, PollIntervalEffectiveS: int(d.PollInterval(s.pollEvery()) / time.Second),
|
|
MgmtIP: d.MgmtIP, SysName: d.SysName, SysObjectID: d.SysObjectID, SysDescr: d.SysDescr, HWSerial: d.HWSerial,
|
|
PollState: pollStateOf(d), Warnings: s.warningsOf(d), LastSeen: utcp(d.LastSeen), CreatedAt: d.CreatedAt.UTC(),
|
|
RetiredAt: utcp(d.RetiredAt), Addresses: []string{}, Parents: []DeviceRef{}, Groups: []GroupView{},
|
|
Reachability: s.Reachability.Reachability(ctx, []store.Device{*d})[d.Slug],
|
|
}
|
|
if err := s.fillRelations(ctx, d, &v); err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
if err := s.fillInterfaces(ctx, d, &v); err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
if err := s.fillM5(ctx, d, &v); err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
return &deviceOut{Body: v}, nil
|
|
}
|
|
|
|
// fillRelations adds addresses, the credential's version (never its secrets), parents and groups.
|
|
func (s *server) fillRelations(ctx context.Context, d *store.Device, v *DeviceView) error {
|
|
addrs, err := s.Store.ListAddresses(ctx, d.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, a := range addrs {
|
|
v.Addresses = append(v.Addresses, a.Addr)
|
|
}
|
|
if d.SNMPCredentialID != nil {
|
|
c, err := s.Store.GetCredential(ctx, *d.SNMPCredentialID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v.SNMPVersion = c.Version
|
|
}
|
|
links, err := s.Store.ListParents(ctx, d.ID, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seen := map[int64]bool{}
|
|
for _, l := range links {
|
|
if seen[l.ParentID] {
|
|
continue
|
|
}
|
|
seen[l.ParentID] = true
|
|
p, err := s.Store.GetDeviceByID(ctx, l.ParentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v.Parents = append(v.Parents, DeviceRef{Slug: p.Slug, Name: p.Name})
|
|
}
|
|
groups, err := s.Store.DeviceGroups(ctx, d.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, g := range groups {
|
|
v.Groups = append(v.Groups, GroupView{ID: itoa(int(g.ID)), Name: g.Name, Kind: g.Kind})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fillInterfaces adds the device's interfaces, retired ones included, with live rates.
|
|
func (s *server) fillInterfaces(ctx context.Context, d *store.Device, v *DeviceView) error {
|
|
ifs, err := s.Store.ListInterfaces(ctx, d.ID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var rates map[string]*ifRates
|
|
v.Interfaces = make([]InterfaceView, 0, len(ifs))
|
|
if len(ifs) > 0 || d.Kind == store.KindNetwork {
|
|
rates, v.UptimeSeconds = s.liveRates(ctx, `device=`+quote(d.Slug), true)
|
|
}
|
|
for i := range ifs {
|
|
v.Interfaces = append(v.Interfaces, interfaceView(&ifs[i], rates[ifs[i].ID]))
|
|
}
|
|
return s.withAlertDown(ctx, ifs, v.Interfaces)
|
|
}
|
|
|
|
func interfaceView(i *store.Interface, r *ifRates) InterfaceView {
|
|
in, out := r.bps(i)
|
|
inE, outE := r.errs(i)
|
|
return InterfaceView{
|
|
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias, Description: i.Description,
|
|
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
|
|
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
|
|
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(), Unreliable: i.Unreliable,
|
|
RetiredAt: utcp(i.RetiredAt), InBPS: in, OutBPS: out, InErrorsPS: inE, OutErrorsPS: outE,
|
|
}
|
|
}
|
|
|
|
// unreliableReason says why an interface's counters cannot be trusted.
|
|
func unreliableReason(i *store.Interface) string {
|
|
switch {
|
|
case !i.HasHCCounters:
|
|
return "counter_32bit_fast_link"
|
|
case i.CounterDiscontinuity > 0:
|
|
return "counter_discontinuity"
|
|
}
|
|
return "counter_unreliable"
|
|
}
|