Files
nmon/internal/api/device_view.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
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
2026-10-25 06:31:12 +00:00

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