GET /api/v1/devices/{slug}/overview (traffic, ports, availability, rtt, events);
metrics/graph gains aggregate=avg|sum|max when component is omitted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
150 lines
6.1 KiB
Go
150 lines
6.1 KiB
Go
package api_test
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
func overviewEnv(t *testing.T) (*env, *store.Device) {
|
|
e := newEnvDeps(t, 0, withShipped(t))
|
|
d := e.device(t, "sw1", "yyz", "access")
|
|
return e, d
|
|
}
|
|
|
|
func hoursAgo(e *env, n float64) *time.Time {
|
|
x := e.clk.Now().Add(-time.Duration(n * float64(time.Hour)))
|
|
return &x
|
|
}
|
|
|
|
func TestOverviewShape(t *testing.T) {
|
|
e, d := overviewEnv(t)
|
|
e.iface(t, d, 1, "up1", "")
|
|
e.iface(t, d, 2, "dn1", "", func(i *store.Interface) { i.OperStatus = 2 })
|
|
e.iface(t, d, 3, "ad1", "", func(i *store.Interface) { i.AdminStatus, i.OperStatus = 2, 2 })
|
|
e.iface(t, d, 4, "soft", "", func(i *store.Interface) { i.HasHCCounters = false })
|
|
e.iface(t, d, 5, "bad", "", func(i *store.Interface) { i.Unreliable = true })
|
|
e.iface(t, d, 6, "ex", "", func(i *store.Interface) { i.Excluded = true })
|
|
now := e.clk.Now().Unix()
|
|
e.q.fn = func(_, expr string, _, _ time.Time, _ time.Duration) ([]byte, error) {
|
|
if strings.HasPrefix(expr, "nmon_rtt") {
|
|
return matrix(mrow{map[string]string{}, map[int64]float64{now - now%300: 0.002}}), nil
|
|
}
|
|
return matrix(mrow{map[string]string{}, map[int64]float64{now - now%300: 5e6}}), nil
|
|
}
|
|
var got api.OverviewResponse
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/overview?range=7d", &got)
|
|
if got.Range != "7d" || got.StepSeconds != 660 || got.Ports != (api.OverviewPorts{Total: 5, Up: 3, Down: 1, AdminDown: 1}) {
|
|
t.Fatalf("got %+v", got)
|
|
}
|
|
if got.Traffic.UnreliableExcluded != 1 || len(got.Traffic.In) == 0 || len(got.Traffic.Out) != len(got.Traffic.In) || len(got.RTT) != len(got.Traffic.In) {
|
|
t.Fatalf("traffic %d/%d rtt %d excl %d", len(got.Traffic.In), len(got.Traffic.Out), len(got.RTT), got.Traffic.UnreliableExcluded)
|
|
}
|
|
var traffic []string
|
|
for _, c := range e.q.queries() {
|
|
if strings.HasPrefix(c.expr, "sum(") {
|
|
traffic = append(traffic, c.expr)
|
|
}
|
|
}
|
|
if len(traffic) != 2 || !strings.Contains(traffic[0], "ifHCInOctets") || !strings.Contains(traffic[0], "ifInOctets{") ||
|
|
strings.Contains(traffic[0], "ifOutOctets") || !strings.Contains(traffic[1], "ifHCOutOctets") {
|
|
t.Fatalf("traffic queries = %q", traffic)
|
|
}
|
|
if got.Availability.Day != nil || got.Events == nil {
|
|
t.Errorf("availability %+v events %v", got.Availability, got.Events)
|
|
}
|
|
}
|
|
|
|
func TestOverviewHotStoreDownIsPartial(t *testing.T) {
|
|
e, d := overviewEnv(t)
|
|
e.iface(t, d, 1, "up1", "")
|
|
e.q.err = errors.New("boom")
|
|
e.q.fn = nil
|
|
var got api.OverviewResponse
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/overview", &got)
|
|
if got.Range != "24h" || got.Ports.Total != 1 || len(got.Traffic.In) != 0 || len(got.RTT) != 0 || len(got.Partial) == 0 {
|
|
t.Fatalf("got %+v", got)
|
|
}
|
|
isProblem(t, e.do("GET", "/api/v1/devices/nope/overview", ""), 404)
|
|
isProblem(t, e.do("GET", "/api/v1/devices/"+d.Slug+"/overview?range=9d", ""), 422)
|
|
}
|
|
|
|
func TestOverviewAvailability(t *testing.T) {
|
|
e, d := overviewEnv(t)
|
|
ctx := t.Context()
|
|
e.clk.Advance(48 * time.Hour) // the device is two days old
|
|
add := func(i int, fired, cleared *time.Time) {
|
|
a := &store.Alert{Rule: "device_down", DeviceSlug: d.Slug, SeriesKey: "device_down|" + d.Slug + "|" + string(rune('a'+i)),
|
|
State: store.AlertResolved, BaseState: store.AlertResolved, FiredAt: fired, ClearedAt: cleared, FlapChanges: "[]", Severity: "critical", Summary: "down"}
|
|
if err := e.st.UpsertAlertState(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
add(0, hoursAgo(e, 10), hoursAgo(e, 8)) // 2h
|
|
add(1, hoursAgo(e, 9), hoursAgo(e, 7)) // overlaps: union 10h..7h = 3h
|
|
add(2, hoursAgo(e, 1), nil) // open: 1h
|
|
add(3, hoursAgo(e, 300), hoursAgo(e, 100)) // before the device existed: no effect
|
|
var got api.OverviewResponse
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/overview", &got)
|
|
a := got.Availability
|
|
day := 100 * (1 - 4.0/24)
|
|
age := 100 * (1 - 4.0/48)
|
|
if a.Day == nil || *a.Day < day-0.001 || *a.Day > day+0.001 {
|
|
t.Errorf("24h = %v want %v", a.Day, day)
|
|
}
|
|
for name, v := range map[string]*float64{"7d": a.Week, "30d": a.Month, "1y": a.Year} { // clipped to created_at: 48h
|
|
if v == nil || *v < age-0.001 || *v > age+0.001 {
|
|
t.Errorf("%s = %v want %v", name, v, age)
|
|
}
|
|
}
|
|
if len(got.Events) != 5 && len(got.Events) != 7 {
|
|
t.Errorf("events = %+v", got.Events)
|
|
}
|
|
}
|
|
|
|
func TestOverviewEventsMergeNewestFirst(t *testing.T) {
|
|
e, d := overviewEnv(t)
|
|
i := e.iface(t, d, 1, "up1", "")
|
|
ctx := t.Context()
|
|
a := &store.Alert{Rule: "device_down", DeviceSlug: d.Slug, SeriesKey: "k", State: store.AlertResolved, BaseState: store.AlertResolved,
|
|
FiredAt: hoursAgo(e, 3), ClearedAt: hoursAgo(e, 1), FlapChanges: "[]", Severity: "critical", Summary: "sw1 down"}
|
|
if err := e.st.UpsertAlertState(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = e.st.AppendAudit(ctx, "ann", "interface.alert_down", i.ID, nil, nil)
|
|
_ = e.st.AppendAudit(ctx, "ann", "device.edit", "other", nil, nil)
|
|
var got api.OverviewResponse
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/overview", &got)
|
|
if len(got.Events) != 3 {
|
|
t.Fatalf("events = %+v", got.Events)
|
|
}
|
|
kinds := got.Events[0].Kind + got.Events[1].Kind + got.Events[2].Kind
|
|
if kinds != "auditalertalert" || got.Events[1].Severity != "critical" || got.Events[0].Severity != "" ||
|
|
!strings.HasPrefix(got.Events[2].Summary, "Fired") || !strings.HasPrefix(got.Events[1].Summary, "Resolved") {
|
|
t.Fatalf("events = %+v", got.Events)
|
|
}
|
|
}
|
|
|
|
func TestMetricGraphAggregate(t *testing.T) {
|
|
e := newEnvDeps(t, 0, withShipped(t))
|
|
d := e.device(t, "srv1", "yyz", "server")
|
|
now := e.clk.Now().Unix()
|
|
e.q.body = matrix(mrow{map[string]string{}, map[int64]float64{now - 600: 40}})
|
|
var got api.MetricGraphResponse
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/metrics/graph?metric=hrProcessorLoad&aggregate=avg", &got)
|
|
if want := `avg(hrProcessorLoad{device="` + d.Slug + `"})`; e.q.q != want {
|
|
t.Errorf("expr %q want %q", e.q.q, want)
|
|
}
|
|
if len(got.Series) == 0 {
|
|
t.Error("no series")
|
|
}
|
|
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/metrics/graph?metric=hrProcessorLoad&aggregate=max&component=CPU+0", &got)
|
|
if !strings.Contains(e.q.q, `component="CPU 0"`) || strings.Contains(e.q.q, "max(") {
|
|
t.Errorf("explicit component must win: %q", e.q.q)
|
|
}
|
|
}
|