Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
194 lines
7.3 KiB
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
|
|
}
|