Files
nmon/internal/api/device_put.go
Vibe Coder 881f0ae3b1 Poll every 300 s by default, with a per-device poll interval
The owner wants LibreNMS-like 5-minute polling by default and the option
to poll a given device faster or slower. Devices gain poll_interval_s
(new migration; 0 means the default, otherwise 30 to 3600 s), settable in
the device edit dialog and through PUT /api/v1/devices/{slug}; the
poller picks it up on the next inventory refresh.

Everything that assumed one-minute samples now follows the device's
interval: graph and p95 rate windows are at least two polls, the max
series uses irate, the up-series lookback, the no_data warning, the
"unheard of for three polls" fallback and the rollup's thin-window check.
interface_errors widens to 10 m. profiletest keeps 60 s because the
expect budgets are keyed by the 1m group. Recorded as DEVIATIONS 49.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-24 18:55:43 +00:00

174 lines
5.7 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// PutDeviceBody is the body of PUT /api/v1/devices/{slug}. Absent fields stay
// as they are; an empty parents list removes every parent.
type PutDeviceBody struct {
Name *string `json:"name,omitempty" minLength:"1" maxLength:"255"`
Site *string `json:"site,omitempty"`
Role *string `json:"role,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Parents []string `json:"parents,omitempty" doc:"Parent device slugs, replacing the current parents."`
SNMP *SNMPBody `json:"snmp,omitempty" doc:"Replaces the SNMP credential; secrets are write-only."`
PollIntervalS *int `json:"poll_interval_s,omitempty" minimum:"0" maximum:"3600" doc:"Poll interval, 30 to 3600 s; 0 = default."`
Profile *string `json:"profile,omitempty" doc:"A profile name pins it; an empty string returns the device to automatic matching. A change triggers one discovery."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
}
func isNotFound(err error) bool { return errors.Is(err, store.ErrNotFound) }
func (s *server) putDevice(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
Body PutDeviceBody
}) (*deviceOut, 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 {
return nil, huma.Error409Conflict("device is retired; reattach it before editing")
}
plan, err := s.planEdit(ctx, dev, in.Body)
if err != nil {
return nil, err
}
if err := s.applyEdit(ctx, dev, plan); err != nil {
return nil, s.storeErr(err)
}
s.inventoryChanged(ctx)
if plan.rediscover {
s.rediscoverAfterEdit(ctx, dev.Slug)
}
return s.view(ctx, dev.Slug)
}
// editPlan is a validated edit: the device as it will be, the parents to set
// (nil leaves them) and the credential to create (nil leaves it).
type editPlan struct {
after store.Device
setParents bool
parents []*store.Device
cred *store.SNMPCredential
change map[string]any // what the audit row records; never a secret
rediscover bool // the profile or its pin changed: run one discovery after the write
}
// planEdit validates body against the device without writing anything.
func (s *server) planEdit(ctx context.Context, dev *store.Device, b PutDeviceBody) (*editPlan, error) {
p := &editPlan{after: *dev, change: map[string]any{}}
set := func(field string, dst *string, v *string) {
if v != nil && *v != *dst {
*dst = *v
p.change[field] = *v
}
}
set("name", &p.after.Name, b.Name)
set("site", &p.after.Site, b.Site)
set("role", &p.after.Role, b.Role)
if b.Name != nil && strings.TrimSpace(*b.Name) == "" {
return nil, huma.Error422UnprocessableEntity("name must not be empty")
}
if b.Enabled != nil && *b.Enabled != dev.Enabled {
p.after.Enabled = *b.Enabled
p.change["enabled"] = *b.Enabled
}
if b.Parents != nil {
parents, err := s.resolveParents(ctx, dev, b.Parents)
if err != nil {
return nil, err
}
p.setParents, p.parents = true, parents
p.change["parents"] = b.Parents
}
if err := planPollInterval(p, b.PollIntervalS); err != nil {
return nil, err
}
if b.Profile != nil {
if err := s.planProfile(p, dev, *b.Profile); err != nil {
return nil, err
}
}
if b.SNMP != nil {
if dev.Kind != store.KindNetwork {
return nil, huma.Error422UnprocessableEntity("snmp applies to network devices only")
}
cred, err := s.credential(b.SNMP)
if err != nil {
return nil, err
}
p.cred = cred
p.after.SNMPPort = b.SNMP.Port
p.change["snmp"] = map[string]any{"version": b.SNMP.Version, "port": b.SNMP.Port, "replaced": true}
}
return p, nil
}
// planPollInterval validates the poll-interval override: 0 clears it, otherwise it must be in bounds.
func planPollInterval(p *editPlan, v *int) error {
if v == nil || *v == p.after.PollIntervalS {
return nil
}
if *v != 0 && (*v < store.MinPollIntervalS || *v > store.MaxPollIntervalS) {
return huma.Error422UnprocessableEntity(fmt.Sprintf("poll_interval_s must be 0 or %d to %d",
store.MinPollIntervalS, store.MaxPollIntervalS))
}
p.after.PollIntervalS = *v
p.change["poll_interval_s"] = *v
return nil
}
// applyEdit writes the plan and its audit row in one transaction. The old
// credential is deleted once nothing uses it.
func (s *server) applyEdit(ctx context.Context, dev *store.Device, p *editPlan) error {
return s.Store.WithTx(ctx, func(tx store.Store) error {
after := p.after
oldCred := dev.SNMPCredentialID
if p.cred != nil {
if err := tx.CreateCredential(ctx, p.cred); err != nil {
return err
}
after.SNMPCredentialID = &p.cred.ID
}
if err := tx.UpdateDevice(ctx, &after); err != nil {
return err
}
if p.setParents {
if err := setParentLinks(ctx, tx, &after, p.parents); err != nil {
return err
}
}
if p.cred != nil && oldCred != nil {
if err := tx.DeleteCredential(ctx, *oldCred); err != nil && !errors.Is(err, store.ErrConflict) {
return err
}
}
return tx.AppendAudit(ctx, actorOf(ctx), "device.edit", dev.Slug, nil, p.change)
})
}
// setParentLinks replaces the device's parents. VMs and containers depend on
// their host (hosted_on); everything else on the network path.
func setParentLinks(ctx context.Context, tx store.Store, d *store.Device, parents []*store.Device) error {
rel := store.RelNetwork
if d.Kind == store.KindVM || d.Kind == store.KindContainer {
rel = store.RelHostedOn
}
ids := make([]int64, len(parents))
for i, p := range parents {
ids[i] = p.ID
}
return tx.SetParents(ctx, d.ID, rel, ids)
}