package api import ( "context" "net/http" "strconv" "strings" "time" "github.com/danielgtaylor/huma/v2" "github.com/v10networks/nmon/internal/store" ) // GroupSummary is a group in a list. type GroupSummary struct { GroupView MemberCount int `json:"member_count" doc:"Interfaces in the group now."` AlertDown bool `json:"alert_down" doc:"Member ports alert on interface_down while they are members."` } // GroupList is every group, by name. type GroupList struct { Items []GroupSummary `json:"items"` } // GroupMemberIface names a member interface and its device. type GroupMemberIface struct { ID string `json:"id"` IfName string `json:"ifname"` IfAlias string `json:"ifalias"` Device DeviceRef `json:"device"` } // GroupMemberView is one membership: an interface in force for [effective_from, effective_to). type GroupMemberView struct { Interface GroupMemberIface `json:"interface"` Direction string `json:"direction" enum:"toward,away" doc:"toward: the port's in is traffic toward the group's subject; away: it is traffic away, so in and out swap in the sum."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap EffectiveFrom time.Time `json:"effective_from"` EffectiveTo *time.Time `json:"effective_to" nullable:"true" doc:"Null while the membership is open."` } // GroupFlag is a warning about a group's membership. type GroupFlag struct { Kind string `json:"kind" enum:"both_ends_of_link" doc:"both_ends_of_link: the group holds both ends of one link and would count it twice."` A string `json:"a" doc:"First interface id."` B string `json:"b" doc:"Second interface id."` } // GroupDetail is a group with its membership history. type GroupDetail struct { GroupSummary Members []GroupMemberView `json:"members" doc:"Every membership, past and present, ordered by interface and start."` Flags []GroupFlag `json:"flags"` } // GroupBody is the body of POST and PUT /groups. type GroupBody struct { Name string `json:"name" minLength:"1" maxLength:"100"` Kind string `json:"kind" enum:"site,customer,role,custom" default:"custom"` // AlertDown is a pointer so a PUT that omits it leaves the flag alone. AlertDown *bool `json:"alert_down,omitempty" doc:"Opt member ports into interface_down alerts. Omitted: off on create, kept on update."` } // GroupMemberBody is the body of POST /groups/{id}/members. type GroupMemberBody struct { InterfaceID string `json:"interface_id" minLength:"1"` Direction string `json:"direction" enum:"toward,away"` EffectiveFrom *time.Time `json:"effective_from,omitempty" doc:"Default now. A past date applies the membership to history."` } func (s *server) registerGroups(api huma.API) { tags := []string{"groups"} huma.Register(api, huma.Operation{ OperationID: "list-groups", Method: http.MethodGet, Path: "/api/v1/groups", Tags: tags, Summary: "List groups", }, s.listGroups) huma.Register(api, huma.Operation{ OperationID: "create-group", Method: http.MethodPost, Path: "/api/v1/groups", Tags: tags, DefaultStatus: http.StatusCreated, Summary: "Create a group", Errors: []int{http.StatusConflict, http.StatusUnprocessableEntity}, }, s.createGroup) huma.Register(api, huma.Operation{ OperationID: "get-group", Method: http.MethodGet, Path: "/api/v1/groups/{id}", Tags: tags, Summary: "A group with its membership history and warnings", Errors: []int{http.StatusNotFound}, }, s.getGroup) huma.Register(api, huma.Operation{ OperationID: "update-group", Method: http.MethodPut, Path: "/api/v1/groups/{id}", Tags: tags, Summary: "Rename a group or change its kind", Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity}, }, s.updateGroup) huma.Register(api, huma.Operation{ OperationID: "delete-group", Method: http.MethodDelete, Path: "/api/v1/groups/{id}", Tags: tags, DefaultStatus: http.StatusNoContent, Summary: "Delete a group and its membership history", Description: "A group named by a maintenance window cannot be deleted.", Errors: []int{http.StatusNotFound, http.StatusConflict}, }, s.deleteGroup) huma.Register(api, huma.Operation{ OperationID: "add-group-member", Method: http.MethodPost, Path: "/api/v1/groups/{id}/members", Tags: tags, Summary: "Add an interface to a group", DefaultStatus: http.StatusCreated, Description: "Opens a membership at effective_from (default now). A port that changes direction is ended and added again, " + "so the history of the sums never changes.", Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity}, }, s.addGroupMember) huma.Register(api, huma.Operation{ OperationID: "end-group-member", Method: http.MethodDelete, Path: "/api/v1/groups/{id}/members", Tags: tags, Summary: "End an interface's open membership", DefaultStatus: http.StatusNoContent, Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity}, }, s.endGroupMember) } func summaryOf(now time.Time, g store.Group, members []store.GroupMember) GroupSummary { n := 0 for _, m := range members { if m.GroupID == g.ID && m.Covers(now) { n++ } } gv := GroupView{ID: strconv.FormatInt(g.ID, 10), Name: g.Name, Kind: g.Kind} return GroupSummary{GroupView: gv, MemberCount: n, AlertDown: g.AlertDown} } func (s *server) listGroups(ctx context.Context, _ *struct{}) (*struct{ Body GroupList }, error) { gs, err := s.Store.ListGroups(ctx) if err != nil { return nil, s.storeErr(err) } members, err := s.Store.GroupMembers(ctx, 0, time.Time{}, time.Time{}) if err != nil { return nil, s.storeErr(err) } out := GroupList{Items: make([]GroupSummary, 0, len(gs))} for _, g := range gs { out.Items = append(out.Items, summaryOf(s.Clock.Now(), g, members)) } return &struct{ Body GroupList }{out}, nil } // groupBody validates name and kind. func groupBody(b GroupBody) (store.Group, error) { g := store.Group{Name: strings.TrimSpace(b.Name), Kind: b.Kind} if g.Kind == "" { g.Kind = store.GroupCustom } if g.Name == "" { return g, huma.Error422UnprocessableEntity("name must not be empty") } g.AlertDown = b.AlertDown != nil && *b.AlertDown return g, nil } func (s *server) createGroup(ctx context.Context, in *struct{ Body GroupBody }) (*struct{ Body GroupSummary }, error) { g, err := groupBody(in.Body) if err != nil { return nil, err } err = s.Store.WithTx(ctx, func(tx store.Store) error { if err := tx.CreateGroup(ctx, &g); err != nil { return err } return tx.AppendAudit(ctx, actorOf(ctx), "group.create", "group:"+strconv.FormatInt(g.ID, 10), nil, in.Body) }) if err != nil { return nil, s.storeErr(err) } s.alertDownChanged() return &struct{ Body GroupSummary }{summaryOf(s.Clock.Now(), g, nil)}, nil } func (s *server) getGroup(ctx context.Context, in *struct { ID string `path:"id" doc:"Group id."` }) (*struct{ Body GroupDetail }, error) { id, err := idParam(in.ID) if err != nil { return nil, err } ref, err := s.Store.GetGroup(ctx, id) if err != nil { return nil, s.storeErr(err) } members, err := s.Store.GroupMembers(ctx, id, time.Time{}, time.Time{}) if err != nil { return nil, s.storeErr(err) } g := store.Group{ID: ref.ID, Name: ref.Name, Kind: ref.Kind, AlertDown: ref.AlertDown} d := GroupDetail{GroupSummary: summaryOf(s.Clock.Now(), g, members), Members: make([]GroupMemberView, 0, len(members)), Flags: []GroupFlag{}} for _, m := range members { d.Members = append(d.Members, s.memberView(ctx, m)) } d.Flags = s.groupFlags(ctx, id) return &struct{ Body GroupDetail }{d}, nil } func (s *server) memberView(ctx context.Context, m store.GroupMember) GroupMemberView { v := GroupMemberView{ Interface: GroupMemberIface{ID: m.InterfaceID}, Direction: m.Direction, EffectiveFrom: m.EffectiveFrom.UTC(), EffectiveTo: utcp(m.EffectiveTo), } if i, err := s.Store.GetInterface(ctx, m.InterfaceID); err == nil { v.Interface.IfName, v.Interface.IfAlias = i.IfName, i.IfAlias if d, err := s.Store.GetDeviceByID(ctx, i.DeviceID); err == nil { v.Interface.Device = DeviceRef{Slug: d.Slug, Name: d.Name} } } return v } // groupFlags are the membership warnings; without the traffic service there are none to show. func (s *server) groupFlags(ctx context.Context, id int64) []GroupFlag { out := []GroupFlag{} if s.Groups == nil { return out } fs, err := s.Groups.Flags(ctx, id) if err != nil { s.Logger.Warn("group flags", "group", id, "err", err) return out } for _, f := range fs { out = append(out, GroupFlag{Kind: f.Kind, A: f.A, B: f.B}) } return out } func (s *server) updateGroup(ctx context.Context, in *struct { ID string `path:"id" doc:"Group id."` Body GroupBody }) (*struct{ Body GroupSummary }, error) { id, err := idParam(in.ID) if err != nil { return nil, err } g, err := groupBody(in.Body) if err != nil { return nil, err } g.ID = id if in.Body.AlertDown == nil { cur, err := s.Store.GetGroup(ctx, id) if err != nil { return nil, s.storeErr(err) } g.AlertDown = cur.AlertDown } err = s.Store.WithTx(ctx, func(tx store.Store) error { if err := tx.UpdateGroup(ctx, g); err != nil { return err } return tx.AppendAudit(ctx, actorOf(ctx), "group.update", "group:"+in.ID, nil, in.Body) }) if err != nil { return nil, s.storeErr(err) } s.alertDownChanged() members, err := s.Store.GroupMembers(ctx, id, time.Time{}, time.Time{}) if err != nil { return nil, s.storeErr(err) } return &struct{ Body GroupSummary }{summaryOf(s.Clock.Now(), g, members)}, nil } func (s *server) deleteGroup(ctx context.Context, in *struct { ID string `path:"id" doc:"Group id."` }) (*struct{}, error) { id, err := idParam(in.ID) if err != nil { return nil, err } err = s.Store.WithTx(ctx, func(tx store.Store) error { if err := tx.DeleteGroup(ctx, id); err != nil { return err } return tx.AppendAudit(ctx, actorOf(ctx), "group.delete", "group:"+in.ID, nil, nil) }) if err != nil { return nil, s.storeErr(err) } s.alertDownChanged() return &struct{}{}, nil } func (s *server) addGroupMember(ctx context.Context, in *struct { ID string `path:"id" doc:"Group id."` Body GroupMemberBody }) (*struct{ Body GroupMemberView }, error) { id, err := idParam(in.ID) if err != nil { return nil, err } m := store.GroupMember{GroupID: id, InterfaceID: in.Body.InterfaceID, Direction: in.Body.Direction} if in.Body.EffectiveFrom != nil { m.EffectiveFrom = in.Body.EffectiveFrom.UTC() } else { m.EffectiveFrom = s.Clock.Now().UTC() } err = s.Store.WithTx(ctx, func(tx store.Store) error { if _, err := tx.GetGroup(ctx, id); err != nil { return err } if err := tx.AddGroupMember(ctx, m); err != nil { return err } return tx.AppendAudit(ctx, actorOf(ctx), "group.member.add", "group:"+in.ID, nil, in.Body) }) if err != nil { return nil, s.storeErr(err) } s.alertDownChanged() return &struct{ Body GroupMemberView }{s.memberView(ctx, m)}, nil } func (s *server) endGroupMember(ctx context.Context, in *struct { ID string `path:"id" doc:"Group id."` InterfaceID string `query:"interface_id" required:"true" doc:"Interface whose open membership ends."` At time.Time `query:"at" doc:"End instant, RFC 3339. Default now."` }) (*struct{}, error) { id, err := idParam(in.ID) if err != nil { return nil, err } at := in.At.UTC() if in.At.IsZero() { at = time.Time{} } err = s.Store.WithTx(ctx, func(tx store.Store) error { if err := tx.EndGroupMember(ctx, id, in.InterfaceID, at); err != nil { return err } return tx.AppendAudit(ctx, actorOf(ctx), "group.member.end", "group:"+in.ID, nil, map[string]string{"interface_id": in.InterfaceID}) }) if err != nil { return nil, s.storeErr(err) } s.alertDownChanged() return &struct{}{}, nil }