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
174 lines
5.7 KiB
Go
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)
|
|
}
|