Commit f23c9048 authored by Orne Brocaar's avatar Orne Brocaar
Browse files

Improve gateway and device metrics in API and web-interface.

parent 64e9b59c
......@@ -5,7 +5,7 @@ go 1.16
require (
github.com/NickBall/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5
github.com/aws/aws-sdk-go v1.26.3
github.com/brocaar/chirpstack-api/go/v3 v3.10.0
github.com/brocaar/chirpstack-api/go/v3 v3.11.0
github.com/brocaar/lorawan v0.0.0-20210629093152-95aaed5ba796
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/dgrijalva/jwt-go v3.2.0+incompatible
......
......@@ -273,6 +273,17 @@ func (a *ApplicationServerAPI) HandleError(ctx context.Context, req *as.HandleEr
return nil, grpc.Errorf(codes.Internal, errStr)
}
metrics := storage.MetricsRecord{
Time: time.Now(),
Metrics: map[string]float64{
fmt.Sprintf("error_%s", req.Type.String()): 1.0,
},
}
if err := storage.SaveMetrics(ctx, fmt.Sprintf("device:%s", d.DevEUI), metrics); err != nil {
return nil, errors.Wrap(err, "save metrics error")
}
return &empty.Empty{}, nil
}
......@@ -490,6 +501,27 @@ func (a *ApplicationServerAPI) HandleGatewayStats(ctx context.Context, req *as.H
"tx_ok_count": float64(req.TxPacketsEmitted),
},
}
for k, v := range req.TxPacketsPerFrequency {
metrics.Metrics[fmt.Sprintf("tx_freq_%d", k)] = float64(v)
}
for k, v := range req.RxPacketsPerFrequency {
metrics.Metrics[fmt.Sprintf("rx_freq_%d", k)] = float64(v)
}
for k, v := range req.TxPacketsPerDr {
metrics.Metrics[fmt.Sprintf("tx_dr_%d", k)] = float64(v)
}
for k, v := range req.RxPacketsPerDr {
metrics.Metrics[fmt.Sprintf("rx_dr_%d", k)] = float64(v)
}
for k, v := range req.TxPacketsPerStatus {
metrics.Metrics[fmt.Sprintf("tx_status_%s", k)] = float64(v)
}
if err := storage.SaveMetrics(ctx, fmt.Sprintf("gw:%s", gatewayID), metrics); err != nil {
return nil, helpers.ErrToRPCError(errors.Wrap(err, "save metrics error"))
}
......
......@@ -43,6 +43,9 @@ func (ts *ASTestSuite) SetupSuite() {
func (ts *ASTestSuite) TestApplicationServer() {
assert := require.New(ts.T())
assert.NoError(storage.SetAggregationIntervals([]storage.AggregationInterval{storage.AggregationMinute}))
storage.SetMetricsTTL(time.Minute, time.Minute, time.Minute, time.Minute)
nsClient := nsmock.NewClient()
networkserver.SetPool(nsmock.NewPool(nsClient))
......@@ -128,6 +131,7 @@ func (ts *ASTestSuite) TestApplicationServer() {
api := NewApplicationServerAPI()
ts.T().Run("HandleError", func(t *testing.T) {
start := time.Now()
assert := require.New(t)
_, err := api.HandleError(ctx, &as.HandleErrorRequest{
......@@ -154,12 +158,25 @@ func (ts *ASTestSuite) TestApplicationServer() {
},
PublishedAt: pl.PublishedAt,
}, pl)
stop := time.Now()
// metrics
metrics, err := storage.GetMetrics(context.Background(), storage.AggregationMinute, "device:0102030405060708", start, stop)
assert.NoError(err)
assert.Len(metrics, 1)
assert.Equal(map[string]float64{
"error_DATA_UP_FCNT_RESET": 1.0,
}, metrics[0].Metrics)
})
ts.T().Run("HandleUplinkDataRequest", func(t *testing.T) {
t.Run("With DeviceSecurityContext", func(t *testing.T) {
assert := require.New(t)
// make sure stats are all flushed
storage.RedisClient().FlushAll(context.Background())
now := time.Now().UTC()
uplinkID, err := uuid.NewV4()
assert.NoError(err)
......@@ -217,6 +234,18 @@ func (ts *ASTestSuite) TestApplicationServer() {
assert.NoError(err)
assert.Equal(lorawan.DevAddr{0x01, 0x02, 0x03, 0x04}, d.DevAddr)
assert.Equal(lorawan.AES128Key{0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8}, d.AppSKey)
stop := time.Now()
metrics, err := storage.GetMetrics(context.Background(), storage.AggregationMinute, "device:0102030405060708", now, stop)
assert.NoError(err)
assert.Len(metrics, 1)
assert.Equal(map[string]float64{
"gw_rssi_sum": -60,
"gw_snr_sum": 5,
"rx_count": 1,
"rx_dr_6": 1,
"rx_freq_868100000": 1,
}, metrics[0].Metrics)
})
t.Run("Activated device", func(t *testing.T) {
......@@ -351,9 +380,6 @@ func (ts *ASTestSuite) TestApplicationServer() {
nowPB, err := ptypes.TimestampProto(now)
assert.NoError(err)
assert.NoError(storage.SetAggregationIntervals([]storage.AggregationInterval{storage.AggregationMinute}))
storage.SetMetricsTTL(time.Minute, time.Minute, time.Minute, time.Minute)
stats := as.HandleGatewayStatsRequest{
GatewayId: gw.MAC[:],
Time: nowPB,
......@@ -366,6 +392,22 @@ func (ts *ASTestSuite) TestApplicationServer() {
RxPacketsReceivedOk: 9,
TxPacketsReceived: 8,
TxPacketsEmitted: 7,
TxPacketsPerFrequency: map[uint32]uint32{
868100000: 7,
},
RxPacketsPerFrequency: map[uint32]uint32{
868300000: 9,
},
TxPacketsPerDr: map[uint32]uint32{
3: 7,
},
RxPacketsPerDr: map[uint32]uint32{
2: 9,
},
TxPacketsPerStatus: map[string]uint32{
"OK": 7,
"TOO_LATE": 1,
},
Metadata: map[string]string{
"foo": "bar",
},
......@@ -381,10 +423,16 @@ func (ts *ASTestSuite) TestApplicationServer() {
assert.Len(metrics, 1)
assert.Equal(map[string]float64{
"rx_count": 10,
"rx_ok_count": 9,
"tx_count": 8,
"tx_ok_count": 7,
"rx_count": 10,
"rx_ok_count": 9,
"tx_count": 8,
"tx_ok_count": 7,
"tx_freq_868100000": 7,
"rx_freq_868300000": 9,
"tx_dr_3": 7,
"rx_dr_2": 9,
"tx_status_OK": 7,
"tx_status_TOO_LATE": 1,
}, metrics[0].Metrics)
assert.Equal(start.UTC(), metrics[0].Time.UTC())
......
......@@ -3,6 +3,8 @@ package external
import (
"database/sql"
"encoding/json"
"strconv"
"strings"
"github.com/gofrs/uuid"
"github.com/golang/protobuf/ptypes"
......@@ -888,6 +890,82 @@ func (a *DeviceAPI) GetRandomDevAddr(ctx context.Context, req *pb.GetRandomDevAd
}, nil
}
// GetStats returns the device statistics.
func (a *DeviceAPI) GetStats(ctx context.Context, req *pb.GetDeviceStatsRequest) (*pb.GetDeviceStatsResponse, error) {
var devEUI lorawan.EUI64
if err := devEUI.UnmarshalText([]byte(req.DevEui)); err != nil {
return nil, grpc.Errorf(codes.InvalidArgument, "bad dev_eui: %s", err)
}
err := a.validator.Validate(ctx, auth.ValidateNodeAccess(devEUI, auth.Read))
if err != nil {
return nil, grpc.Errorf(codes.Unauthenticated, "authentication failed: %s", err)
}
start, err := ptypes.Timestamp(req.StartTimestamp)
if err != nil {
return nil, grpc.Errorf(codes.InvalidArgument, err.Error())
}
end, err := ptypes.Timestamp(req.EndTimestamp)
if err != nil {
return nil, grpc.Errorf(codes.InvalidArgument, err.Error())
}
_, ok := ns.AggregationInterval_value[strings.ToUpper(req.Interval)]
if !ok {
return nil, grpc.Errorf(codes.InvalidArgument, "bad interval: %s", req.Interval)
}
metrics, err := storage.GetMetrics(ctx, storage.AggregationInterval(strings.ToUpper(req.Interval)), "device:"+devEUI.String(), start, end)
if err != nil {
return nil, helpers.ErrToRPCError(err)
}
result := make([]*pb.DeviceStats, len(metrics))
for i, m := range metrics {
result[i] = &pb.DeviceStats{
RxPackets: uint32(m.Metrics["rx_count"]),
RxPacketsPerFrequency: make(map[uint32]uint32),
RxPacketsPerDr: make(map[uint32]uint32),
Errors: make(map[string]uint32),
}
result[i].Timestamp, err = ptypes.TimestampProto(m.Time)
if err != nil {
return nil, helpers.ErrToRPCError(err)
}
if (m.Metrics["rx_count"]) != 0 {
result[i].GwRssi = float32(m.Metrics["gw_rssi_sum"] / m.Metrics["rx_count"])
result[i].GwSnr = float32(m.Metrics["gw_snr_sum"] / m.Metrics["rx_count"])
}
for k, v := range m.Metrics {
if strings.HasPrefix(k, "rx_freq_") {
if freq, err := strconv.ParseUint(strings.TrimPrefix(k, "rx_freq_"), 10, 32); err == nil {
result[i].RxPacketsPerFrequency[uint32(freq)] = uint32(v)
}
}
if strings.HasPrefix(k, "rx_dr_") {
if freq, err := strconv.ParseUint(strings.TrimPrefix(k, "rx_dr_"), 10, 32); err == nil {
result[i].RxPacketsPerDr[uint32(freq)] = uint32(v)
}
}
if strings.HasPrefix(k, "error_") {
e := strings.TrimPrefix(k, "error_")
result[i].Errors[e] = uint32(v)
}
}
}
return &pb.GetDeviceStatsResponse{
Result: result,
}, nil
}
func (a *DeviceAPI) returnList(count int, devices []storage.DeviceListItem) (*pb.ListDeviceResponse, error) {
resp := pb.ListDeviceResponse{
TotalCount: int64(count),
......
......@@ -7,6 +7,7 @@ import (
"github.com/gofrs/uuid"
"github.com/golang/protobuf/proto"
"github.com/golang/protobuf/ptypes"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
"google.golang.org/grpc"
......@@ -26,6 +27,9 @@ import (
func (ts *APITestSuite) TestDevice() {
assert := require.New(ts.T())
assert.NoError(storage.SetAggregationIntervals([]storage.AggregationInterval{storage.AggregationMinute}))
storage.SetMetricsTTL(time.Minute, time.Minute, time.Minute, time.Minute)
nsClient := mock.NewClient()
networkserver.SetPool(mock.NewPool(nsClient))
......@@ -667,6 +671,47 @@ func (ts *APITestSuite) TestDevice() {
assert.Equal(eventlog.Join, resp.Type)
})
t.Run("GetStats", func(t *testing.T) {
assert := require.New(t)
metrics := storage.MetricsRecord{
Time: time.Now(),
Metrics: map[string]float64{
"rx_count": 2,
"gw_rssi_sum": -120.0,
"gw_snr_sum": 10.0,
"rx_freq_868100000": 2,
"rx_dr_2": 2,
"error_TOO_LATE": 1,
},
}
assert.NoError(storage.SaveMetrics(context.Background(), "device:0807060504030201", metrics))
resp, err := api.GetStats(context.Background(), &pb.GetDeviceStatsRequest{
DevEui: "0807060504030201",
Interval: "MINUTE",
StartTimestamp: ptypes.TimestampNow(),
EndTimestamp: ptypes.TimestampNow(),
})
assert.NoError(err)
assert.Len(resp.Result, 1)
resp.Result[0].Timestamp = nil
assert.Equal(&pb.DeviceStats{
RxPackets: 2,
GwRssi: -60,
GwSnr: 5,
RxPacketsPerFrequency: map[uint32]uint32{
868100000: 2,
},
RxPacketsPerDr: map[uint32]uint32{
2: 2,
},
Errors: map[string]uint32{
"TOO_LATE": 1,
},
}, resp.Result[0])
})
t.Run("Delete", func(t *testing.T) {
assert := require.New(t)
......
......@@ -2,6 +2,7 @@ package external
import (
"database/sql"
"strconv"
"strings"
"github.com/gofrs/uuid"
......@@ -668,16 +669,52 @@ func (a *GatewayAPI) GetStats(ctx context.Context, req *pb.GetGatewayStatsReques
result := make([]*pb.GatewayStats, len(metrics))
for i, m := range metrics {
result[i] = &pb.GatewayStats{
RxPacketsReceived: int32(m.Metrics["rx_count"]),
RxPacketsReceivedOk: int32(m.Metrics["rx_ok_count"]),
TxPacketsReceived: int32(m.Metrics["tx_count"]),
TxPacketsEmitted: int32(m.Metrics["tx_ok_count"]),
RxPacketsReceived: int32(m.Metrics["rx_count"]),
RxPacketsReceivedOk: int32(m.Metrics["rx_ok_count"]),
TxPacketsReceived: int32(m.Metrics["tx_count"]),
TxPacketsEmitted: int32(m.Metrics["tx_ok_count"]),
TxPacketsPerDr: make(map[uint32]uint32),
RxPacketsPerDr: make(map[uint32]uint32),
TxPacketsPerFrequency: make(map[uint32]uint32),
RxPacketsPerFrequency: make(map[uint32]uint32),
TxPacketsPerStatus: make(map[string]uint32),
}
result[i].Timestamp, err = ptypes.TimestampProto(m.Time)
if err != nil {
return nil, helpers.ErrToRPCError(err)
}
for k, v := range m.Metrics {
if strings.HasPrefix(k, "tx_freq_") {
if freq, err := strconv.ParseUint(strings.TrimPrefix(k, "tx_freq_"), 10, 32); err == nil {
result[i].TxPacketsPerFrequency[uint32(freq)] = uint32(v)
}
}
if strings.HasPrefix(k, "rx_freq_") {
if freq, err := strconv.ParseUint(strings.TrimPrefix(k, "rx_freq_"), 10, 32); err == nil {
result[i].RxPacketsPerFrequency[uint32(freq)] = uint32(v)
}
}
if strings.HasPrefix(k, "tx_dr_") {
if freq, err := strconv.ParseUint(strings.TrimPrefix(k, "tx_dr_"), 10, 32); err == nil {
result[i].TxPacketsPerDr[uint32(freq)] = uint32(v)
}
}
if strings.HasPrefix(k, "rx_dr_") {
if freq, err := strconv.ParseUint(strings.TrimPrefix(k, "rx_dr_"), 10, 32); err == nil {
result[i].RxPacketsPerDr[uint32(freq)] = uint32(v)
}
}
if strings.HasPrefix(k, "tx_status_") {
status := strings.TrimPrefix(k, "tx_status_")
result[i].TxPacketsPerStatus[status] = uint32(v)
}
}
}
return &pb.GetGatewayStatsResponse{
......
......@@ -340,10 +340,16 @@ func (ts *APITestSuite) TestGateway() {
metrics := storage.MetricsRecord{
Time: now,
Metrics: map[string]float64{
"rx_count": 10,
"rx_ok_count": 5,
"tx_count": 11,
"tx_ok_count": 10,
"rx_count": 10,
"rx_ok_count": 5,
"tx_count": 11,
"tx_ok_count": 10,
"tx_freq_868100000": 10,
"rx_freq_868300000": 5,
"tx_dr_3": 10,
"rx_dr_2": 5,
"tx_status_OK": 10,
"tx_status_TOO_LATE": 1,
},
}
assert.NoError(storage.SaveMetricsForInterval(context.Background(), storage.AggregationMinute, "gw:0102030405060708", metrics))
......@@ -367,6 +373,22 @@ func (ts *APITestSuite) TestGateway() {
RxPacketsReceivedOk: 5,
TxPacketsReceived: 11,
TxPacketsEmitted: 10,
TxPacketsPerFrequency: map[uint32]uint32{
868100000: 10,
},
RxPacketsPerFrequency: map[uint32]uint32{
868300000: 5,
},
TxPacketsPerDr: map[uint32]uint32{
3: 10,
},
RxPacketsPerDr: map[uint32]uint32{
2: 5,
},
TxPacketsPerStatus: map[string]uint32{
"OK": 10,
"TOO_LATE": 1,
},
}, *resp.Result[0])
})
......
......@@ -44,6 +44,7 @@ var tasks = []func(*uplinkContext) error{
decryptPayload,
handleCodec,
handleIntegrations,
saveMetrics,
}
// Handle handles the uplink event.
......@@ -294,6 +295,44 @@ func handleIntegrations(ctx *uplinkContext) error {
return nil
}
func saveMetrics(ctx *uplinkContext) error {
var maxRSSI int32
var maxSNR float64
for i, rxInfo := range ctx.uplinkDataReq.GetRxInfo() {
if i == 0 {
maxRSSI = rxInfo.Rssi
maxSNR = rxInfo.LoraSnr
}
if rxInfo.Rssi > maxRSSI {
maxRSSI = rxInfo.Rssi
}
if rxInfo.LoraSnr > maxSNR {
maxSNR = rxInfo.LoraSnr
}
}
// note that the RSS and SNR needs to be divided by the rx_count
metrics := storage.MetricsRecord{
Time: time.Now(),
Metrics: map[string]float64{
"rx_count": 1.0,
"gw_rssi_sum": float64(maxRSSI),
"gw_snr_sum": maxSNR,
fmt.Sprintf("rx_freq_%d", ctx.uplinkDataReq.GetTxInfo().Frequency): 1.0,
fmt.Sprintf("rx_dr_%d", ctx.uplinkDataReq.Dr): 1.0,
},
}
if err := storage.SaveMetrics(ctx.ctx, fmt.Sprintf("device:%s", ctx.device.DevEUI), metrics); err != nil {
return errors.Wrap(err, "save metrics error")
}
return nil
}
func unwrapASKey(ke *common.KeyEnvelope) (lorawan.AES128Key, error) {
var key lorawan.AES128Key
......
......@@ -3175,30 +3175,19 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
"chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.0.tgz",
"integrity": "sha512-J1a4EAb1Gi/KbhwDRmoovHTRuqT8qdF0kZ4XgwxpGethJHUdDrkqyPYwke0a+BuvSeUxPf8Cos6AX2AB8H8GLA=="
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
"chartjs-adapter-moment": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.0.tgz",
"integrity": "sha512-PqlerEvQcc5hZLQ/NQWgBxgVQ4TRdvkW3c/t+SUEQSj78ia3hgLkf2VZ2yGJtltNbEEFyYGm+cA6XXevodYvWA=="
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"requires": {
"color-name": "^1.0.0"
}
"chartjs-chart-matrix": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-1.0.2.tgz",
"integrity": "sha512-Zil3L9CgQk+MN0AD8WPTaJ+N6RH89N3qAsJoIRGVr909yu/iSIFMrWBE2bmjoOTDf0+XqIrr7UkXO8mXErEhbw=="
},
"chokidar": {
"version": "3.3.1",
......@@ -11037,12 +11026,11 @@
}
},
"react-chartjs-2": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.9.0.tgz",
"integrity": "sha512-IYwqUUnQRAJ9SNA978vxulHJTcUFTJk2LDVfbAyk0TnJFZZG7+6U/2flsE4MCw6WCbBjTTypy8T82Ch7XrPtRw==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz",
"integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==",
"requires": {
"lodash": "^4.17.4",
"prop-types": "^15.5.8"
"lodash": "^4.17.19"
}
},
"react-codemirror2": {
......
......@@ -6,7 +6,9 @@
"@fortawesome/fontawesome-free": "^5.13.0",
"@material-ui/core": "^4.9.10",
"@material-ui/lab": "^4.0.0-alpha.49",
"chart.js": "^2.9.4",
"chart.js": "^3.5.0",
"chartjs-adapter-moment": "^1.0.0",
"chartjs-chart-matrix": "^1.0.2",
"classnames": "^2.2.6",
"codemirror": "^5.58.2",
"flux": "^3.1.3",
......@@ -18,7 +20,7 @@
"moment": "^2.24.0",
"query-string": "^6.12.1",
"react": "^16.13.1",
"react-chartjs-2": "^2.9.0",
"react-chartjs-2": "^3.0.4",
"react-codemirror2": "^7.1.0",
"react-dom": "^16.13.1",
"react-json-tree": "^0.11.2",
......
import React, { Component } from "react";
import { color } from "chart.js/helpers";
import ChartComponent from "react-chartjs-2";
class Heatmap extends Component {
render() {
if (this.props.data === undefined) {
return null;
}
let data = {
labels: [],
datasets: [
{
label: "Heatmap",