Files
nmon/internal/api/alerts.go

194 lines
7.3 KiB
Go

package api
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/notify"
"github.com/v10networks/nmon/internal/store"
)
// IfaceRef names the interface an alert is about.
type IfaceRef struct {
ID string `json:"id" doc:"Interface ULID."`
IfName string `json:"ifname"`
}
// AlertAck is an acknowledgement: it silences repeat notifications until Until.
type AlertAck struct {
By string `json:"by"`
Reason string `json:"reason"`
Until time.Time `json:"until"`
}
// AlertView is one alert: one incident of one rule on one device, interface or component.
type AlertView struct {
ID string `json:"id" doc:"Opaque alert identifier."`
Rule string `json:"rule" example:"device_down"`
Severity string `json:"severity" enum:"critical,warning,info"`
State string `json:"state" enum:"pending,firing,flapping,stale,resolved,retired"`
Device DeviceRef `json:"device"`
Iface *IfaceRef `json:"iface,omitempty"`
Component string `json:"component,omitempty" doc:"What on the device: a storage row, a sensor, or the reachability warning reason."`
Value float64 `json:"value" doc:"The last measured value; 0 for reachability alerts."`
Threshold float64 `json:"threshold"`
Summary string `json:"summary"`
FiredAt *time.Time `json:"fired_at" nullable:"true"`
ClearedAt *time.Time `json:"cleared_at" nullable:"true"`
Ack *AlertAck `json:"ack,omitempty" doc:"Present while an acknowledgement has not expired."`
// SuppressedBy names what keeps this alert from notifying: parent devices that are all down, or "maintenance".
SuppressedBy []string `json:"suppressed_by"`
IncidentID string `json:"incident_id" doc:"Stable across retries and the resolution; empty before the alert fires."`
Description string `json:"description"`
}
// AlertList is a page of AlertView.
type AlertList struct {
Items []AlertView `json:"items"`
Page int64 `json:"page"`
PerPage int64 `json:"per_page"`
Total int64 `json:"total"`
}
// AckBody is the body of POST /alerts/{id}/ack.
type AckBody struct {
Reason string `json:"reason" maxLength:"500" doc:"Why the alert is acknowledged."`
Until time.Time `json:"until" doc:"When the acknowledgement expires; must be in the future."`
}
func (s *server) registerAlerts(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "list-alerts", Method: http.MethodGet, Path: "/api/v1/alerts", Tags: []string{"alerts"},
Summary: "Alerts, newest first",
Description: "state=active lists firing, flapping and stale alerts that are not acknowledged, acked those that are, " +
"history the resolved ones. Without state, all of them. Alerts that have not fired yet are never listed.",
}, s.listAlerts)
huma.Register(api, huma.Operation{
OperationID: "ack-alert", Method: http.MethodPost, Path: "/api/v1/alerts/{id}/ack", Tags: []string{"alerts"},
Summary: "Acknowledge an active alert",
Description: "Repeat notifications stop until until; the resolution is still sent. A resolved alert is a 409. " +
"until must be in the future.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity},
}, s.ackAlert)
s.registerDeliveries(api)
}
// alertStates are the states a listing shows by group.
var (
openAlertStates = []string{store.AlertFiring, store.AlertFlapping, store.AlertStale}
listedAlertStates = []string{store.AlertFiring, store.AlertFlapping, store.AlertStale, store.AlertResolved}
)
func (s *server) listAlerts(ctx context.Context, in *struct {
PageParams
State string `query:"state" enum:"active,acked,open,history" doc:"active: unacked. acked: acked. open: either. history: closed."`
Device string `query:"device" doc:"Only alerts of this device slug."`
Iface string `query:"iface" doc:"Only alerts of this interface ULID."`
}) (*struct{ Body AlertList }, error) {
f := store.AlertFilter{States: listedAlertStates, Device: in.Device, Iface: in.Iface}
switch in.State {
case "active":
f.States, f.Acked = openAlertStates, ptrBool(false)
case "acked":
f.States, f.Acked = openAlertStates, ptrBool(true)
case "open":
f.States = openAlertStates
case "history":
f.States = []string{store.AlertResolved}
}
rows, total, err := s.Store.ListAlerts(ctx, f, store.Page{Offset: in.offset(), Limit: in.limit()})
if err != nil {
return nil, s.storeErr(err)
}
out := AlertList{Items: make([]AlertView, 0, len(rows)), Page: in.pageNo(), PerPage: in.limit(), Total: total}
names := s.nameLookup()
for i := range rows {
out.Items = append(out.Items, s.alertView(ctx, &rows[i], names))
}
return &struct{ Body AlertList }{out}, nil
}
func ptrBool(b bool) *bool { return &b }
// nameLookup returns a per-request lookup of device names by slug.
func (s *server) nameLookup() func(ctx context.Context, slug string) DeviceRef {
seen := map[string]DeviceRef{}
return func(ctx context.Context, slug string) DeviceRef {
if r, ok := seen[slug]; ok {
return r
}
r := DeviceRef{Slug: slug, Name: slug}
if d, err := s.Store.GetDeviceBySlug(ctx, slug); err == nil {
r.Name = d.Name
}
seen[slug] = r
return r
}
}
// alertView renders a row. Lookups that fail leave the slug or interface ID in place of the name.
func (s *server) alertView(ctx context.Context, a *store.Alert, name func(context.Context, string) DeviceRef) AlertView {
v := AlertView{
ID: strconv.FormatInt(a.ID, 10), Rule: a.Rule, Severity: a.Severity, State: a.State, Device: name(ctx, a.DeviceSlug),
Component: a.Component, Value: a.Value, Threshold: a.Threshold, Summary: a.Summary, Description: a.Description,
FiredAt: utcp(a.FiredAt), ClearedAt: utcp(a.ClearedAt), SuppressedBy: splitSuppressed(a.SuppressedBy),
}
if a.FiredAt != nil {
v.IncidentID = notify.IncidentID(a.ID, *a.FiredAt)
}
if a.Iface != "" {
v.Iface = &IfaceRef{ID: a.Iface, IfName: a.Iface}
if i, err := s.Store.GetInterface(ctx, a.Iface); err == nil {
v.Iface.IfName = i.IfName
}
}
if a.AckUntil != nil && a.AckUntil.After(s.Clock.Now()) {
v.Ack = &AlertAck{By: a.AckBy, Reason: a.AckReason, Until: a.AckUntil.UTC()}
}
return v
}
// splitSuppressed turns the stored ", "-joined reason into a list.
func splitSuppressed(s string) []string {
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func (s *server) ackAlert(ctx context.Context, in *struct {
ID string `path:"id" doc:"Alert id."`
Body AckBody
}) (*struct{ Body AlertView }, error) {
id, err := strconv.ParseInt(in.ID, 10, 64)
if err != nil {
return nil, huma.Error404NotFound("not found")
}
if !in.Body.Until.After(s.Clock.Now()) {
return nil, huma.Error422UnprocessableEntity("until must be in the future")
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.AckAlert(ctx, id, actorOf(ctx), in.Body.Reason, in.Body.Until.UTC()); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "alert.ack", "alert:"+in.ID, nil,
map[string]any{"reason": in.Body.Reason, "until": in.Body.Until.UTC()})
})
if err != nil {
return nil, s.storeErr(err)
}
a, err := s.Store.GetAlert(ctx, id)
if err != nil {
return nil, s.storeErr(err)
}
return &struct{ Body AlertView }{s.alertView(ctx, a, s.nameLookup())}, nil
}