Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
137 lines
4.9 KiB
Go
137 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// DeviceSummary is one row of the device list.
|
|
type DeviceSummary struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind" enum:"network,vm,container,check_only"`
|
|
Site string `json:"site"`
|
|
Role string `json:"role"`
|
|
MgmtIP string `json:"mgmt_ip"`
|
|
Profile string `json:"profile"`
|
|
Reachability string `json:"reachability" enum:"up,down,warning,unknown,maintenance"`
|
|
PollState string `json:"poll_state" enum:"ok,degraded,backoff"`
|
|
LastSeen *time.Time `json:"last_seen" nullable:"true"`
|
|
InterfacesTotal int64 `json:"interfaces_total" doc:"Active interfaces, excluded ones left out."`
|
|
InterfacesDown int64 `json:"interfaces_down" doc:"Active interfaces that are admin up and not oper up."`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
// DeviceList is a page of DeviceSummary.
|
|
type DeviceList struct {
|
|
Items []DeviceSummary `json:"items"`
|
|
Page int64 `json:"page"`
|
|
PerPage int64 `json:"per_page"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
// DeviceFacets are the distinct values the list's filter dropdowns offer.
|
|
type DeviceFacets struct {
|
|
Sites []string `json:"sites"`
|
|
Roles []string `json:"roles"`
|
|
Kinds []string `json:"kinds"`
|
|
}
|
|
|
|
func (s *server) registerDeviceList(api huma.API) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "list-devices", Method: http.MethodGet, Path: "/api/v1/devices", Tags: []string{"devices"},
|
|
Summary: "List devices",
|
|
Description: "Paged, with reachability and interface tallies. state filters on the derived reachability, " +
|
|
"so it is applied after the other filters.",
|
|
}, s.listDevices)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-device-facets", Method: http.MethodGet, Path: "/api/v1/devices/facets", Tags: []string{"devices"},
|
|
Summary: "Distinct site, role and kind values of unretired devices",
|
|
}, s.deviceFacets)
|
|
}
|
|
|
|
func (s *server) listDevices(ctx context.Context, in *struct {
|
|
Site string `query:"site"`
|
|
Role string `query:"role"`
|
|
Kind string `query:"kind" enum:"network,vm,container,check_only"`
|
|
Q string `query:"q" doc:"Substring of name, slug, management address or sysName."`
|
|
State string `query:"state" enum:"up,down,warning,unknown,maintenance" doc:"Reachability."`
|
|
PageParams
|
|
}) (*struct{ Body DeviceList }, error) {
|
|
f := store.DeviceFilter{Site: in.Site, Role: in.Role, Kind: in.Kind, Q: in.Q}
|
|
var (
|
|
devs []store.Device
|
|
total int64
|
|
reach map[string]string
|
|
err error
|
|
)
|
|
if in.State == "" {
|
|
devs, total, err = s.Store.ListDevices(ctx, f, store.Page{Offset: in.offset(), Limit: in.limit()})
|
|
if err == nil {
|
|
reach = s.Reachability.Reachability(ctx, devs)
|
|
}
|
|
} else {
|
|
devs, total, reach, err = s.devicesInState(ctx, f, in.State, in.PageParams)
|
|
}
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
counts, err := s.Store.InterfaceCounts(ctx)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
out := DeviceList{Items: make([]DeviceSummary, 0, len(devs)), Page: in.pageNo(), PerPage: in.limit(), Total: total}
|
|
for i := range devs {
|
|
d := &devs[i]
|
|
c := counts[d.ID]
|
|
out.Items = append(out.Items, DeviceSummary{
|
|
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, MgmtIP: d.MgmtIP, Profile: d.Profile,
|
|
Reachability: reach[d.Slug], PollState: pollStateOf(d), LastSeen: utcp(d.LastSeen),
|
|
InterfacesTotal: c.Total, InterfacesDown: c.Down, Warnings: s.warningsOf(d),
|
|
})
|
|
}
|
|
return &struct{ Body DeviceList }{out}, nil
|
|
}
|
|
|
|
// devicesInState pages over the devices whose derived reachability is state.
|
|
// Reachability is not a column, so every device matching the other filters is
|
|
// classified first and the page is cut from the survivors.
|
|
func (s *server) devicesInState(
|
|
ctx context.Context, f store.DeviceFilter, state string, p PageParams,
|
|
) (page []store.Device, total int64, reach map[string]string, err error) {
|
|
var all []store.Device
|
|
for off := int64(0); ; off += store.MaxLimit {
|
|
rows, n, err := s.Store.ListDevices(ctx, f, store.Page{Offset: off, Limit: store.MaxLimit})
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
all = append(all, rows...)
|
|
if off+store.MaxLimit >= n {
|
|
break
|
|
}
|
|
}
|
|
reach = s.Reachability.Reachability(ctx, all)
|
|
var match []store.Device
|
|
for _, d := range all {
|
|
if reach[d.Slug] == state {
|
|
match = append(match, d)
|
|
}
|
|
}
|
|
lo := min(p.offset(), int64(len(match)))
|
|
hi := min(lo+p.limit(), int64(len(match)))
|
|
return match[lo:hi], int64(len(match)), reach, nil
|
|
}
|
|
|
|
func (s *server) deviceFacets(ctx context.Context, _ *struct{}) (*struct{ Body DeviceFacets }, error) {
|
|
sites, roles, kinds, err := s.Store.DeviceFacets(ctx)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
return &struct{ Body DeviceFacets }{DeviceFacets{Sites: nonNil(sites), Roles: nonNil(roles), Kinds: nonNil(kinds)}}, nil
|
|
}
|