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