Files
nmon/internal/api/maintenance.go

196 lines
7.0 KiB
Go

package api
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// MaintenanceView is a maintenance window: notifications for the device, or for
// every device with an interface in the group, are held between starts_at and ends_at.
type MaintenanceView struct {
ID string `json:"id"`
Device *DeviceRef `json:"device,omitempty" doc:"Set when the window covers one device."`
Group *GroupView `json:"group,omitempty" doc:"Set when the window covers a group."`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
Reason string `json:"reason"`
State string `json:"state" enum:"scheduled,active,ended"`
}
// MaintenanceList is a page of MaintenanceView.
type MaintenanceList struct {
Items []MaintenanceView `json:"items"`
Page int64 `json:"page"`
PerPage int64 `json:"per_page"`
Total int64 `json:"total"`
}
// MaintenanceBody is the body of POST /maintenance. Give exactly one of device and group_id.
type MaintenanceBody struct {
Device string `json:"device,omitempty" doc:"Device slug."`
GroupID string `json:"group_id,omitempty" doc:"Group id, as shown in a device's groups."`
StartsAt *time.Time `json:"starts_at,omitempty" doc:"Default: now."`
EndsAt time.Time `json:"ends_at" doc:"Must be after starts_at and in the future."`
Reason string `json:"reason" maxLength:"500"`
}
func (s *server) registerMaintenance(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "list-maintenance", Method: http.MethodGet, Path: "/api/v1/maintenance", Tags: []string{"alerts"},
Summary: "Maintenance windows, newest first", Description: "Windows that have ended are left out unless past=true.",
}, s.listMaintenance)
huma.Register(api, huma.Operation{
OperationID: "create-maintenance", Method: http.MethodPost, Path: "/api/v1/maintenance", Tags: []string{"alerts"},
Summary: "Open a maintenance window", DefaultStatus: http.StatusCreated,
Description: "While a window is open its devices show maintenance and send no notifications; alerts keep their " +
"state and anything still wrong when the window closes is announced then.",
Errors: []int{http.StatusUnprocessableEntity},
}, s.createMaintenance)
huma.Register(api, huma.Operation{
OperationID: "delete-maintenance", Method: http.MethodDelete, Path: "/api/v1/maintenance/{id}", Tags: []string{"alerts"},
Summary: "Delete a maintenance window", Description: "Ends its effect at once.", DefaultStatus: http.StatusNoContent,
Errors: []int{http.StatusNotFound},
}, s.deleteMaintenance)
}
func (s *server) listMaintenance(ctx context.Context, in *struct {
PageParams
Past bool `query:"past" doc:"Include windows that have ended."`
}) (*struct{ Body MaintenanceList }, error) {
rows, total, err := s.Store.ListMaintenanceWindows(ctx, in.Past, store.Page{Offset: in.offset(), Limit: in.limit()})
if err != nil {
return nil, s.storeErr(err)
}
out := MaintenanceList{Items: make([]MaintenanceView, 0, len(rows)), Page: in.pageNo(), PerPage: in.limit(), Total: total}
for i := range rows {
v, err := s.maintenanceView(ctx, &rows[i])
if err != nil {
return nil, s.storeErr(err)
}
out.Items = append(out.Items, v)
}
return &struct{ Body MaintenanceList }{out}, nil
}
func (s *server) maintenanceView(ctx context.Context, w *store.MaintenanceWindow) (MaintenanceView, error) {
now := s.Clock.Now()
v := MaintenanceView{
ID: strconv.FormatInt(w.ID, 10), StartsAt: w.StartsAt.UTC(), EndsAt: w.EndsAt.UTC(), Reason: w.Reason, State: "active",
}
switch {
case !w.EndsAt.After(now):
v.State = "ended"
case w.StartsAt.After(now):
v.State = "scheduled"
}
switch {
case w.DeviceID != nil:
d, err := s.Store.GetDeviceByID(ctx, *w.DeviceID)
if err != nil {
return v, err
}
v.Device = &DeviceRef{Slug: d.Slug, Name: d.Name}
case w.GroupID != nil:
g, err := s.Store.GetGroup(ctx, *w.GroupID)
if err != nil {
return v, err
}
v.Group = &GroupView{ID: strconv.FormatInt(g.ID, 10), Name: g.Name, Kind: g.Kind}
}
return v, nil
}
// maintenanceTarget validates the body and resolves the window to store.
func (s *server) maintenanceTarget(ctx context.Context, b MaintenanceBody) (*store.MaintenanceWindow, error) {
now := s.Clock.Now()
w := &store.MaintenanceWindow{StartsAt: now, EndsAt: b.EndsAt.UTC(), Reason: strings.TrimSpace(b.Reason)}
if b.StartsAt != nil {
w.StartsAt = b.StartsAt.UTC()
}
switch {
case (b.Device == "") == (b.GroupID == ""):
return nil, huma.Error422UnprocessableEntity("give exactly one of device and group_id")
case !w.EndsAt.After(w.StartsAt):
return nil, huma.Error422UnprocessableEntity("ends_at must be after starts_at")
case !w.EndsAt.After(now):
return nil, huma.Error422UnprocessableEntity("ends_at must be in the future")
}
if b.Device != "" {
d, err := s.Store.GetDeviceBySlug(ctx, b.Device)
if err != nil || d.RetiredAt != nil || d.PurgedAt != nil {
if err != nil && !isNotFound(err) {
return nil, s.storeErr(err)
}
return nil, huma.Error422UnprocessableEntity("device: no active device with slug " + quote(b.Device))
}
w.DeviceID = &d.ID
return w, nil
}
gid, err := strconv.ParseInt(b.GroupID, 10, 64)
if err == nil {
_, err = s.Store.GetGroup(ctx, gid)
}
if err != nil {
return nil, huma.Error422UnprocessableEntity("group_id: no group " + quote(b.GroupID))
}
w.GroupID = &gid
return w, nil
}
func (s *server) createMaintenance(ctx context.Context, in *struct{ Body MaintenanceBody }) (*struct{ Body MaintenanceView }, error) {
w, err := s.maintenanceTarget(ctx, in.Body)
if err != nil {
return nil, err
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.CreateMaintenanceWindow(ctx, w); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "maintenance.create", "maintenance:"+strconv.FormatInt(w.ID, 10), nil,
map[string]any{"device": in.Body.Device, "group_id": in.Body.GroupID, "ends_at": w.EndsAt, "reason": w.Reason})
})
if err != nil {
return nil, s.storeErr(err)
}
s.maintenanceChanged(ctx)
v, err := s.maintenanceView(ctx, w)
if err != nil {
return nil, s.storeErr(err)
}
return &struct{ Body MaintenanceView }{v}, nil
}
func (s *server) deleteMaintenance(ctx context.Context, in *struct {
ID string `path:"id" doc:"Maintenance window id."`
}) (*struct{}, error) {
id, err := strconv.ParseInt(in.ID, 10, 64)
if err != nil {
return nil, huma.Error404NotFound("not found")
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.DeleteMaintenanceWindow(ctx, id); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "maintenance.delete", "maintenance:"+in.ID, nil, nil)
})
if err != nil {
return nil, s.storeErr(err)
}
s.maintenanceChanged(ctx)
return nil, nil
}
// maintenanceChanged tells the evaluators a window was created or deleted.
func (s *server) maintenanceChanged(ctx context.Context) {
if s.OnMaintenanceChange != nil {
s.OnMaintenanceChange(ctx)
}
}