Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
317 lines
11 KiB
Go
317 lines
11 KiB
Go
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."`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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++
|
|
}
|
|
}
|
|
return GroupSummary{GroupView: GroupView{ID: strconv.FormatInt(g.ID, 10), Name: g.Name, Kind: g.Kind}, MemberCount: n}
|
|
}
|
|
|
|
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")
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
d := GroupDetail{GroupSummary: summaryOf(s.Clock.Now(), store.Group{ID: ref.ID, Name: ref.Name, Kind: ref.Kind}, 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
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
return &struct{}{}, nil
|
|
}
|