Files
nmon/internal/api/alert_down.go
jleung ad220eae58 Interface-down alerts are opt-in: per port (bell toggle, bulk) and via groups
interfaces.alert_down / groups.alert_down (migration 9); interface_down rule OptIn; non-opted ports' open alerts close silently as retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:04:41 +00:00

156 lines
5.5 KiB
Go

package api
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// AlertDownState says whether an interface alerts when it goes down. Own is the port's flag; Effective adds
// membership of a group with the flag on, and Via names the source ("port", or "group:<name>").
type AlertDownState struct {
AlertDown bool `json:"alert_down" doc:"The port's own opt-in for interface_down alerts."`
AlertDownEffective bool `json:"alert_down_effective" doc:"The port alerts when down: own flag, or a group it is in now."`
AlertDownVia string `json:"alert_down_via" doc:"Why it is effective: port, or group:<name>. Empty when it is not."`
}
func alertDownState(own bool, via string) AlertDownState {
return AlertDownState{AlertDown: own, AlertDownEffective: via != "", AlertDownVia: via}
}
// InterfaceAlertBody is the body of PUT /interfaces/{id}.
type InterfaceAlertBody struct {
AlertDown bool `json:"alert_down" doc:"Opt the port in (true) or out (false) of interface_down alerts."`
}
// DeviceAlertDownBody is the body of POST /devices/{slug}/interfaces/alert-down.
type DeviceAlertDownBody struct {
On bool `json:"on" doc:"Opt the ports in (true) or out (false)."`
Ifaces []string `json:"ifaces,omitempty" doc:"Interface ids of this device. Omitted means every active port of the device."`
}
// AlertDownResult reports how many ports a bulk change touched.
type AlertDownResult struct {
Changed int `json:"changed"`
}
func (s *server) registerAlertDown(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "set-interface-alert-down", Method: http.MethodPut, Path: "/api/v1/interfaces/{id}", Tags: []string{"interfaces"},
Summary: "Opt one interface in or out of interface_down alerts",
Description: "Admin only. interface_down alerts are opt-in: a down port raises nothing until its own flag is on " +
"or a group it belongs to has alert_down on.",
Errors: []int{http.StatusNotFound},
}, s.setInterfaceAlertDown)
huma.Register(api, huma.Operation{
OperationID: "set-device-alert-down", Method: http.MethodPost, Path: "/api/v1/devices/{slug}/interfaces/alert-down",
Tags: []string{"devices"},
Summary: "Opt a device's interfaces in or out of interface_down alerts",
Description: "Admin only. Without ifaces the change applies to every active port of the device. " +
"Turning a port off closes its open interface_down alert silently.",
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
}, s.setDeviceAlertDown)
}
func (s *server) alertDownChanged() {
if s.OnAlertDownChange != nil {
s.OnAlertDownChange()
}
}
func (s *server) setInterfaceAlertDown(ctx context.Context, in *struct {
ID string `path:"id" doc:"Interface ULID."`
Body InterfaceAlertBody
}) (*struct{ Body InterfaceView }, error) {
err := s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.SetInterfaceAlertDown(ctx, []string{in.ID}, in.Body.AlertDown); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "interface.alert_down", in.ID, nil, in.Body)
})
if err != nil {
return nil, s.storeErr(err)
}
s.alertDownChanged()
i, err := s.Store.GetInterface(ctx, in.ID)
if err != nil {
return nil, s.storeErr(err)
}
via, err := s.Store.InterfaceAlertDownVia(ctx, []string{in.ID})
if err != nil {
return nil, s.storeErr(err)
}
v := interfaceView(i, nil)
v.AlertDownState = alertDownState(i.AlertDown, via[in.ID])
return &struct{ Body InterfaceView }{v}, nil
}
func (s *server) setDeviceAlertDown(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
Body DeviceAlertDownBody
}) (*struct{ Body AlertDownResult }, error) {
d, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
if err != nil || d.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
ifs, err := s.Store.ListInterfaces(ctx, d.ID, false)
if err != nil {
return nil, s.storeErr(err)
}
ids, err := alertDownTargets(ifs, in.Body.Ifaces)
if err != nil {
return nil, err
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.SetInterfaceAlertDown(ctx, ids, in.Body.On); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "device.interfaces.alert_down", d.Slug, nil,
map[string]any{"on": in.Body.On, "count": len(ids), "all": in.Body.Ifaces == nil})
})
if err != nil {
return nil, s.storeErr(err)
}
s.alertDownChanged()
return &struct{ Body AlertDownResult }{AlertDownResult{Changed: len(ids)}}, nil
}
// alertDownTargets picks the port ids a bulk change applies to: every active port, or the listed ones, which
// must all belong to the device.
func alertDownTargets(ifs []store.Interface, want []string) ([]string, error) {
have := make(map[string]bool, len(ifs))
all := make([]string, 0, len(ifs))
for _, i := range ifs {
have[i.ID] = true
all = append(all, i.ID)
}
if want == nil {
return all, nil
}
for _, id := range want {
if !have[id] {
return nil, huma.Error422UnprocessableEntity("interface " + id + " is not an active port of this device")
}
}
return want, nil
}
// withAlertDown fills the alert_down fields of interface views from one store query.
func (s *server) withAlertDown(ctx context.Context, ifs []store.Interface, views []InterfaceView) error {
ids := make([]string, len(ifs))
for i := range ifs {
ids[i] = ifs[i].ID
}
via, err := s.Store.InterfaceAlertDownVia(ctx, ids)
if err != nil {
return err
}
for i := range views {
views[i].AlertDownState = alertDownState(ifs[i].AlertDown, via[ifs[i].ID])
}
return nil
}