Implement support for quick device measurement metrics.

This commit is contained in:
Orne Brocaar
2022-06-28 15:05:42 +01:00
parent 4fa9341139
commit a01f8565fd
73 changed files with 8695 additions and 3833 deletions

View File

@ -0,0 +1,98 @@
import React, { Component } from "react";
import { Card, Empty } from "antd";
import { TimeUnit } from "chart.js";
import { Bar } from "react-chartjs-2";
import moment from "moment";
import { Metric, Aggregation } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
interface IProps {
metric: Metric;
aggregation: Aggregation;
}
class MetricBar extends Component<IProps> {
render() {
if (this.props.metric.getTimestampsList().length === 0 || this.props.metric.getDatasetsList().length === 0) {
return <Empty />;
}
let unit: TimeUnit = "hour";
if (this.props.aggregation === Aggregation.DAY) {
unit = "day";
} else if (this.props.aggregation === Aggregation.MONTH) {
unit = "month";
}
let backgroundColors = [
"#8bc34a",
"#ff5722",
"#ff9800",
"#ffc107",
"#ffeb3b",
"#cddc39",
"#4caf50",
"#009688",
"#00bcd4",
"#03a9f4",
"#2196f3",
"#3f51b5",
"#673ab7",
"#9c27b0",
"#e91e63",
];
const animation: false = false;
const options = {
animation: animation,
plugins: {
legend: {
display: true,
},
},
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
},
x: {
type: "time" as const,
time: {
unit: unit,
},
},
},
};
let data: {
labels: number[];
datasets: {
label: string;
data: number[];
backgroundColor: string;
}[];
} = {
labels: this.props.metric.getTimestampsList().map(v => moment(v.toDate()).valueOf()),
datasets: [],
};
for (let ds of this.props.metric.getDatasetsList()) {
data.datasets.push({
label: ds.getLabel(),
data: ds.getDataList(),
backgroundColor: backgroundColors.shift()!,
});
}
return (
<Card title={this.props.metric.getName()} className="dashboard-chart">
<Bar data={data} options={options} />
</Card>
);
}
}
export default MetricBar;

View File

@ -0,0 +1,81 @@
import React, { Component } from "react";
import { Card, Empty } from "antd";
import { TimeUnit } from "chart.js";
import { Line } from "react-chartjs-2";
import moment from "moment";
import { Metric, Aggregation } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
interface IProps {
metric: Metric;
aggregation: Aggregation;
zeroToNull?: boolean;
}
class MetricChart extends Component<IProps> {
render() {
if (this.props.metric.getTimestampsList().length === 0 || this.props.metric.getDatasetsList().length === 0) {
return <Empty />;
}
let unit: TimeUnit = "hour";
if (this.props.aggregation === Aggregation.DAY) {
unit = "day";
} else if (this.props.aggregation === Aggregation.MONTH) {
unit = "month";
}
const animation: false = false;
const options = {
animation: animation,
plugins: {
legend: {
display: false,
},
},
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
},
x: {
type: "time" as const,
time: {
unit: unit,
},
},
},
};
let data = {
labels: this.props.metric.getTimestampsList().map(v => moment(v.toDate()).valueOf()),
datasets: this.props.metric.getDatasetsList().map(v => {
return {
label: v.getLabel(),
borderColor: "rgba(33, 150, 243, 1)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineTension: 0,
pointBackgroundColor: "rgba(33, 150, 243, 1)",
data: v.getDataList().map(v => {
if (v === 0 && this.props.zeroToNull) {
return null;
} else {
return v;
}
}),
};
}),
};
return (
<Card title={this.props.metric.getName()} className="dashboard-chart">
<Line height={75} options={options} data={data} />
</Card>
);
}
}
export default MetricChart;

View File

@ -1,44 +1,89 @@
import React, { Component } from "react";
import { color } from "chart.js/helpers";
import { Chart } from "react-chartjs-2";
import { Card, Empty } from "antd";
interface HeatmapData {
x: string;
y: Array<[string, number]>;
}
import { color } from "chart.js/helpers";
import { TimeUnit } from "chart.js";
import { Chart } from "react-chartjs-2";
import moment from "moment";
import { Metric, Aggregation } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
interface IProps {
data: HeatmapData[];
metric: Metric;
fromColor: string;
toColor: string;
aggregation: Aggregation;
}
class Heatmap extends Component<IProps> {
class MetricHeatmap extends Component<IProps> {
render() {
if (this.props.data.length === 0) {
return null;
if (this.props.metric.getTimestampsList().length === 0 || this.props.metric.getDatasetsList().length === 0) {
return <Empty />;
}
let xSet: { [key: string]: any } = {};
let ySet: { [key: string]: any } = {};
let unit: TimeUnit = "hour";
if (this.props.aggregation === Aggregation.DAY) {
unit = "day";
} else if (this.props.aggregation === Aggregation.MONTH) {
unit = "month";
}
const animation: false = false;
let options = {
animation: animation,
maintainAspectRatio: false,
scales: {
y: {
type: "category" as const,
offset: true,
grid: {
display: false,
},
},
x: {
type: "time" as const,
time: {
unit: unit,
},
offset: true,
labels: this.props.metric.getTimestampsList().map(v => moment(v.toDate().valueOf())),
grid: {
display: false,
},
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: () => {
return "";
},
label: (ctx: any) => {
const v = ctx.dataset.data[ctx.dataIndex].v;
return "Count: " + v;
},
},
},
},
};
let dataData: {
x: string;
x: number;
y: string;
v: number;
}[] = [];
let data = {
labels: [],
labels: this.props.metric.getDatasetsList().map(v => v.getLabel()),
datasets: [
{
label: "Heatmap",
data: dataData,
minValue: -1,
maxValue: -1,
xSet: xSet,
ySet: ySet,
fromColor: this.props.fromColor.match(/\d+/g)!.map(Number),
toColor: this.props.toColor.match(/\d+/g)!.map(Number),
backgroundColor: (ctx: any): string => {
@ -64,80 +109,49 @@ class Heatmap extends Component<IProps> {
},
borderWidth: 0,
width: (ctx: any) => {
return (ctx.chart.chartArea || {}).width / Object.keys(ctx.dataset.xSet).length - 1;
return (ctx.chart.chartArea || {}).width / this.props.metric.getTimestampsList().length - 1;
},
height: (ctx: any) => {
return (ctx.chart.chartArea || {}).height / Object.keys(ctx.dataset.ySet).length - 1;
return (ctx.chart.chartArea || {}).height / this.props.metric.getDatasetsList().length - 1;
},
},
],
};
let xLabels: string[] = [];
data.labels.sort();
const animation: false = false;
const tsList = this.props.metric.getTimestampsList();
const dsList = this.props.metric.getDatasetsList();
let options = {
animation: animation,
maintainAspectRatio: false,
scales: {
y: {
type: "category" as const,
offset: true,
grid: {
display: false,
},
},
x: {
type: "time" as const,
offset: true,
labels: xLabels,
grid: {
display: false,
},
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: () => {
return "";
},
label: (ctx: any) => {
const v = ctx.dataset.data[ctx.dataIndex].v;
return "Count: " + v;
},
},
},
},
};
for (const row of this.props.data) {
options.scales.x.labels.push(row.x);
data.datasets[0].xSet[row.x] = {};
for (const y of row.y) {
data.datasets[0].ySet[y[0]] = {};
data.datasets[0].data.push({
x: row.x,
y: y[0],
v: y[1],
});
if (data.datasets[0].minValue === -1 || data.datasets[0].minValue > y[1]) {
data.datasets[0].minValue = y[1];
for (let i = 0; i < tsList.length; i++) {
for (let ds of dsList) {
let v = ds.getDataList()[i];
if (v === 0) {
continue;
}
if (data.datasets[0].maxValue < y[1]) {
data.datasets[0].maxValue = y[1];
data.datasets[0].data.push({
x: moment(tsList[i].toDate()).valueOf(),
y: ds.getLabel(),
v: v,
});
if (data.datasets[0].minValue === -1 || data.datasets[0].minValue > v) {
data.datasets[0].minValue = v;
}
if (data.datasets[0].maxValue < v) {
data.datasets[0].maxValue = v;
}
}
}
return <Chart type="matrix" data={data} options={options} />;
return (
<Card title={this.props.metric.getName()} className="dashboard-chart">
<Chart type="matrix" data={data} options={options} />
</Card>
);
}
}
export default Heatmap;
export default MetricHeatmap;

View File

@ -15,8 +15,6 @@ import {
GetDeviceKeysResponse,
UpdateDeviceKeysRequest,
DeleteDeviceKeysRequest,
GetDeviceStatsRequest,
GetDeviceStatsResponse,
EnqueueDeviceQueueItemRequest,
EnqueueDeviceQueueItemResponse,
FlushDeviceQueueRequest,
@ -28,6 +26,10 @@ import {
ActivateDeviceRequest,
GetRandomDevAddrRequest,
GetRandomDevAddrResponse,
GetDeviceMetricsRequest,
GetDeviceMetricsResponse,
GetDeviceLinkMetricsRequest,
GetDeviceLinkMetricsResponse,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_pb";
import SessionStore from "./SessionStore";
@ -172,8 +174,19 @@ class DeviceStore extends EventEmitter {
});
};
getStats = (req: GetDeviceStatsRequest, callbackFunc: (resp: GetDeviceStatsResponse) => void) => {
this.client.getStats(req, SessionStore.getMetadata(), (err, resp) => {
getMetrics = (req: GetDeviceMetricsRequest, callbackFunc: (resp: GetDeviceMetricsResponse) => void) => {
this.client.getMetrics(req, SessionStore.getMetadata(), (err, resp) => {
if (err !== null) {
HandleError(err);
return;
}
callbackFunc(resp);
});
};
getLinkMetrics = (req: GetDeviceLinkMetricsRequest, callbackFunc: (resp: GetDeviceLinkMetricsResponse) => void) => {
this.client.getLinkMetrics(req, SessionStore.getMetadata(), (err, resp) => {
if (err !== null) {
HandleError(err);
return;

View File

@ -9,8 +9,8 @@ import {
DeleteGatewayRequest,
ListGatewaysRequest,
ListGatewaysResponse,
GetGatewayStatsRequest,
GetGatewayStatsResponse,
GetGatewayMetricsRequest,
GetGatewayMetricsResponse,
GenerateGatewayClientCertificateRequest,
GenerateGatewayClientCertificateResponse,
} from "@chirpstack/chirpstack-api-grpc-web/api/gateway_pb";
@ -96,8 +96,8 @@ class GatewayStore extends EventEmitter {
});
};
getStats = (req: GetGatewayStatsRequest, callbackFunc: (resp: GetGatewayStatsResponse) => void) => {
this.client.getStats(req, SessionStore.getMetadata(), (err, resp) => {
getMetrics = (req: GetGatewayMetricsRequest, callbackFunc: (resp: GetGatewayMetricsResponse) => void) => {
this.client.getMetrics(req, SessionStore.getMetadata(), (err, resp) => {
if (err !== null) {
HandleError(err);
return;

View File

@ -4,7 +4,7 @@ import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs } from
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { DeviceProfileTemplate } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import { CodecRuntime } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import { CodecRuntime, Measurement, MeasurementKind } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import { Region, MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import { ListDeviceProfileAdrAlgorithmsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
@ -102,6 +102,14 @@ class DeviceProfileTemplateForm extends Component<IProps, IState> {
dp.getTagsMap().set(elm[0], elm[1]);
}
// measurements
for (const elm of v.measurementsMap) {
let m = new Measurement();
m.setKind(elm[1].kind);
m.setName(elm[1].name);
dp.getMeasurementsMap().set(elm[0], m);
}
this.props.onFinish(dp);
};
@ -405,6 +413,88 @@ class DeviceProfileTemplateForm extends Component<IProps, IState> {
)}
</Form.List>
</Tabs.TabPane>
<Tabs.TabPane tab="Measurements" key="7">
<Card bordered={false}>
<p>
ChirpStack can aggregate and visualize decoded device measurements in the device dashboard. To setup the
aggregation of device measurements, you must configure the key, kind of measurement and name
(user-defined). Please note that ChirpStack will automatically configure the keys once it has received
the first uplink(s). The following measurement-kinds can be selected:
</p>
<ul>
<li>
<strong>Unknown / unset</strong>: Default for auto-detected keys. This disables the aggregation of
this metric.
</li>
<li>
<strong>Counter</strong>: For continuous incrementing counters.
</li>
<li>
<strong>Absolute</strong>: For counters which get reset upon reading / uplink.
</li>
<li>
<strong>Gauge</strong>: For temperature, humidity, pressure etc...
</li>
<li>
<strong>String</strong>: For boolean or string values.
</li>
</ul>
</Card>
<Form.List name="measurementsMap">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Row gutter={24}>
<Col span={6}>
<Form.Item
{...restField}
name={[name, 0]}
fieldKey={[name, 0]}
rules={[{ required: true, message: "Please enter a key!" }]}
>
<Input placeholder="Key" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
{...restField}
name={[name, 1, "kind"]}
fieldKey={[name, 1, "kind"]}
rules={[{ required: true, message: "Please select a kind!" }]}
>
<Select>
<Select.Option value={MeasurementKind.UNKNOWN}>Unknown / unset</Select.Option>
<Select.Option value={MeasurementKind.COUNTER}>Counter</Select.Option>
<Select.Option value={MeasurementKind.ABSOLUTE}>Absolute</Select.Option>
<Select.Option value={MeasurementKind.GAUGE}>Gauge</Select.Option>
<Select.Option value={MeasurementKind.STRING}>String</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
{...restField}
name={[name, 1, "name"]}
fieldKey={[name, 1, "name"]}
rules={[{ required: true, message: "Please enter a description!" }]}
>
<Input placeholder="Name" />
</Form.Item>
</Col>
<Col span={2}>
<MinusCircleOutlined onClick={() => remove(name)} />
</Col>
</Row>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add measurement
</Button>
</Form.Item>
</>
)}
</Form.List>
</Tabs.TabPane>
</Tabs>
<Form.Item>
<Button type="primary" htmlType="submit">

View File

@ -1,9 +1,14 @@
import React, { Component } from "react";
import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs, Modal, Spin, Cascader } from "antd";
import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs, Modal, Spin, Cascader, Card } from "antd";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { DeviceProfile, CodecRuntime } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import {
DeviceProfile,
CodecRuntime,
Measurement,
MeasurementKind,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import { Region, MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import { ListDeviceProfileAdrAlgorithmsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import {
@ -280,6 +285,14 @@ class DeviceProfileForm extends Component<IProps, IState> {
dp.getTagsMap().set(elm[0], elm[1]);
}
// measurements
for (const elm of v.measurementsMap) {
let m = new Measurement();
m.setKind(elm[1].kind);
m.setName(elm[1].name);
dp.getMeasurementsMap().set(elm[0], m);
}
this.props.onFinish(dp);
};
@ -318,6 +331,8 @@ class DeviceProfileForm extends Component<IProps, IState> {
templateModalVisible: false,
});
console.log(dp.toObject().tagsMap);
this.formRef.current.setFieldsValue({
name: dp.getName(),
description: dp.getDescription(),
@ -339,12 +354,8 @@ class DeviceProfileForm extends Component<IProps, IState> {
abpRx2Dr: dp.getAbpRx2Dr(),
abpRx2Freq: dp.getAbpRx2Freq(),
abpRx1DrOffset: dp.getAbpRx1DrOffset(),
tagsMap: [
["firmware", dp.getFirmware()],
["vendor", dp.getVendor()],
["device", dp.getName()],
["device_profile_template_id", dp.getId()],
],
tagsMap: dp.toObject().tagsMap,
measurementsMap: dp.toObject().measurementsMap,
});
const tabActive = this.state.tabActive;
@ -393,9 +404,16 @@ class DeviceProfileForm extends Component<IProps, IState> {
tabActive: "6",
},
() => {
this.setState({
tabActive: tabActive,
});
this.setState(
{
tabActive: "7",
},
() => {
this.setState({
tabActive: tabActive,
});
},
);
},
);
},
@ -688,6 +706,94 @@ class DeviceProfileForm extends Component<IProps, IState> {
)}
</Form.List>
</Tabs.TabPane>
<Tabs.TabPane tab="Measurements" key="7">
<Card bordered={false}>
<p>
ChirpStack can aggregate and visualize decoded device measurements in the device dashboard. To setup the
aggregation of device measurements, you must configure the key, kind of measurement and name
(user-defined). Please note that ChirpStack will automatically configure the keys once it has received
the first uplink(s). The following measurement-kinds can be selected:
</p>
<ul>
<li>
<strong>Unknown / unset</strong>: Default for auto-detected keys. This disables the aggregation of
this metric.
</li>
<li>
<strong>Counter</strong>: For continuous incrementing counters.
</li>
<li>
<strong>Absolute</strong>: For counters which get reset upon reading / uplink.
</li>
<li>
<strong>Gauge</strong>: For temperature, humidity, pressure etc...
</li>
<li>
<strong>String</strong>: For boolean or string values.
</li>
</ul>
</Card>
<Form.List name="measurementsMap">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Row gutter={24}>
<Col span={6}>
<Form.Item
{...restField}
name={[name, 0]}
fieldKey={[name, 0]}
rules={[{ required: true, message: "Please enter a key!" }]}
>
<Input placeholder="Measurement key" disabled={this.props.disabled} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
{...restField}
name={[name, 1, "kind"]}
fieldKey={[name, 1, "kind"]}
rules={[{ required: true, message: "Please select a kind!" }]}
>
<Select disabled={this.props.disabled} placeholder="Measurement kind">
<Select.Option value={MeasurementKind.UNKNOWN}>Unknown / unset</Select.Option>
<Select.Option value={MeasurementKind.COUNTER}>Counter</Select.Option>
<Select.Option value={MeasurementKind.ABSOLUTE}>Absolute</Select.Option>
<Select.Option value={MeasurementKind.GAUGE}>Gauge</Select.Option>
<Select.Option value={MeasurementKind.STRING}>String</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
{...restField}
name={[name, 1, "name"]}
fieldKey={[name, 1, "name"]}
rules={[{ required: true, message: "Please enter a name!" }]}
>
<Input placeholder="Measurement name" disabled={this.props.disabled} />
</Form.Item>
</Col>
<Col span={2}>
<MinusCircleOutlined onClick={() => remove(name)} />
</Col>
</Row>
))}
<Form.Item>
<Button
disabled={this.props.disabled}
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add measurement
</Button>
</Form.Item>
</>
)}
</Form.List>
</Tabs.TabPane>
</Tabs>
<Form.Item>
<Button type="primary" htmlType="submit" disabled={this.props.disabled}>

View File

@ -2,20 +2,24 @@ import React, { Component } from "react";
import { Link } from "react-router-dom";
import moment from "moment";
import { Descriptions, Space, Card, Row, Col } from "antd";
import { TimeUnit } from "chart.js";
import { Line, Bar } from "react-chartjs-2";
import { ReloadOutlined } from "@ant-design/icons";
import { Descriptions, Space, Card, Statistic, Row, Col, Tabs, Radio, RadioChangeEvent, Button } from "antd";
import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb";
import {
Device,
GetDeviceStatsRequest,
GetDeviceStatsResponse,
GetDeviceMetricsRequest,
GetDeviceMetricsResponse,
GetDeviceLinkMetricsRequest,
GetDeviceLinkMetricsResponse,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_pb";
import { Aggregation } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import { DeviceProfile } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import DeviceStore from "../../stores/DeviceStore";
import Heatmap from "../../components/Heatmap";
import MetricChart from "../../components/MetricChart";
import MetricHeatmap from "../../components/MetricHeatmap";
import MetricBar from "../../components/MetricBar";
interface IProps {
device: Device;
@ -24,17 +28,9 @@ interface IProps {
}
interface IState {
statsUp?: any;
statsErrors?: any;
statsUpFreq: HeatmapStats[];
statsUpDr?: any;
statsGwRssi?: any;
statsGwSnr?: any;
}
interface HeatmapStats {
x: string;
y: Array<[string, number]>;
metricsAggregation: Aggregation;
deviceMetrics?: GetDeviceMetricsResponse;
deviceLinkMetrics?: GetDeviceLinkMetricsResponse;
}
class DeviceDashboard extends Component<IProps, IState> {
@ -42,285 +38,143 @@ class DeviceDashboard extends Component<IProps, IState> {
super(props);
this.state = {
statsUpFreq: [],
metricsAggregation: Aggregation.DAY,
};
}
componentDidMount() {
this.loadStats();
this.loadMetrics();
}
loadStats = () => {
const end = moment().toDate();
const start = moment().subtract(30, "days").toDate();
loadMetrics = () => {
const agg = this.state.metricsAggregation;
const end = moment();
let start = moment();
if (agg === Aggregation.DAY) {
start = start.subtract(30, "days");
} else if (agg === Aggregation.HOUR) {
start = start.subtract(24, "hours");
} else if (agg === Aggregation.MONTH) {
start = start.subtract(12, "months");
}
this.loadLinkMetrics(start.toDate(), end.toDate(), agg);
this.loadDeviceMetrics(start.toDate(), end.toDate(), agg);
};
loadDeviceMetrics = (start: Date, end: Date, agg: Aggregation) => {
let startPb = new Timestamp();
let endPb = new Timestamp();
startPb.fromDate(start);
endPb.fromDate(end);
let req = new GetDeviceStatsRequest();
let req = new GetDeviceMetricsRequest();
req.setDevEui(this.props.device.getDevEui());
req.setStart(startPb);
req.setEnd(endPb);
req.setAggregation(agg);
DeviceStore.getStats(req, (resp: GetDeviceStatsResponse) => {
let statsUp: {
labels: string[];
datasets: {
label: string;
borderColor: string;
backgroundColor: string;
lineTension: number;
pointBackgroundColor: string;
data: number[];
}[];
} = {
labels: [],
datasets: [
{
label: "uplink",
borderColor: "rgba(33, 150, 243, 1)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineTension: 0,
pointBackgroundColor: "rgba(33, 150, 243, 1)",
data: [],
},
],
};
let statsErrors: {
labels: string[];
datasets: {
label: string;
data: number[];
backgroundColor: string;
}[];
} = {
labels: [],
datasets: [],
};
let statsErrorsSet: {
[key: string]: number[];
} = {};
let statsUpDr: {
labels: string[];
datasets: {
label: string;
data: number[];
backgroundColor: string;
}[];
} = {
labels: [],
datasets: [],
};
let statsUpDrSet: {
[key: string]: number[];
} = {};
let statsGwRssiLabels: string[] = [];
let statsGwRssiData: (number | null)[] = [];
let statsGwRssi = {
labels: statsGwRssiLabels,
datasets: [
{
label: "rssi (reported by gateways)",
borderColor: "rgba(33, 150, 243, 1)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineTension: 0,
pointBackgroundColor: "rgba(33, 150, 243, 1)",
data: statsGwRssiData,
},
],
};
let statsGwSnrLabels: string[] = [];
let statsGwSnrData: (number | null)[] = [];
let statsGwSnr = {
labels: statsGwSnrLabels,
datasets: [
{
label: "rssi (reported by gateways)",
borderColor: "rgba(33, 150, 243, 1)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineTension: 0,
pointBackgroundColor: "rgba(33, 150, 243, 1)",
data: statsGwSnrData,
},
],
};
let statsUpFreq: HeatmapStats[] = [];
for (const row of resp.getResultList()) {
statsUp.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsUp.datasets[0].data.push(row.getRxPackets());
statsUpFreq.push({
x: moment(row.getTime()!.toDate()).format("YYYY-MM-DD"),
y: row
.getRxPacketsPerFrequencyMap()
.toObject()
.map(v => [v[0].toString(), v[1]]),
});
statsErrors.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsUpDr.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsGwRssi.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsGwSnr.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
if (row.getRxPackets() !== 0) {
statsGwRssi.datasets[0].data.push(row.getGwRssi());
statsGwSnr.datasets[0].data.push(row.getGwSnr());
} else {
statsGwRssi.datasets[0].data.push(null);
statsGwSnr.datasets[0].data.push(null);
}
for (const v of row.getErrorsMap().toObject()) {
if (statsErrorsSet[v[0]] === undefined) {
statsErrorsSet[v[0]] = [];
}
// fill gaps with 0s
for (let i = statsErrorsSet[v[0]].length; i < statsErrors.labels.length - 1; i++) {
statsErrorsSet[v[0]].push(0);
}
statsErrorsSet[v[0]].push(v[1]);
}
for (const v of row.getRxPacketsPerDrMap().toObject()) {
if (statsUpDrSet[v[0]] === undefined) {
statsUpDrSet[v[0]] = [];
}
// fill gaps with 0s
for (let i = statsUpDrSet[v[0]].length; i < statsUpDr.labels.length - 1; i++) {
statsUpDrSet[v[0]].push(0);
}
statsUpDrSet[v[0]].push(v[1]);
}
}
let backgroundColors = [
"#8bc34a",
"#ff5722",
"#ff9800",
"#ffc107",
"#ffeb3b",
"#cddc39",
"#4caf50",
"#009688",
"#00bcd4",
"#03a9f4",
"#2196f3",
"#3f51b5",
"#673ab7",
"#9c27b0",
"#e91e63",
];
Object.entries(statsErrorsSet).forEach(([k, v]) => {
statsErrors.datasets.push({
label: k,
data: v,
backgroundColor: backgroundColors.shift()!,
});
});
backgroundColors = [
"#8bc34a",
"#ff5722",
"#ff9800",
"#ffc107",
"#ffeb3b",
"#cddc39",
"#4caf50",
"#009688",
"#00bcd4",
"#03a9f4",
"#2196f3",
"#3f51b5",
"#673ab7",
"#9c27b0",
"#e91e63",
];
Object.entries(statsUpDrSet).forEach(([k, v]) => {
statsUpDr.datasets.push({
label: k,
data: v,
backgroundColor: backgroundColors.shift()!,
});
});
DeviceStore.getMetrics(req, (resp: GetDeviceMetricsResponse) => {
this.setState({
statsUp: statsUp,
statsErrors: statsErrors,
statsUpFreq: statsUpFreq,
statsUpDr: statsUpDr,
statsGwRssi: statsGwRssi,
statsGwSnr: statsGwSnr,
deviceMetrics: resp,
});
});
};
loadLinkMetrics = (start: Date, end: Date, agg: Aggregation) => {
let startPb = new Timestamp();
let endPb = new Timestamp();
startPb.fromDate(start);
endPb.fromDate(end);
let req = new GetDeviceLinkMetricsRequest();
req.setDevEui(this.props.device.getDevEui());
req.setStart(startPb);
req.setEnd(endPb);
req.setAggregation(agg);
DeviceStore.getLinkMetrics(req, (resp: GetDeviceLinkMetricsResponse) => {
this.setState({
deviceLinkMetrics: resp,
});
});
};
onMetricsAggregationChange = (e: RadioChangeEvent) => {
this.setState(
{
metricsAggregation: e.target.value,
},
this.loadMetrics,
);
};
render() {
const animation: false = false;
const unit: TimeUnit = "day";
const barOptions = {
animation: animation,
plugins: {
legend: {
display: true,
},
},
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
},
x: {
time: {
unit: unit,
},
},
},
};
const statsOptions = {
animation: animation,
plugins: {
legend: {
display: false,
},
},
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
},
x: {
time: {
unit: unit,
},
},
},
};
if (this.state.statsUpDr === undefined) {
if (this.state.deviceLinkMetrics === undefined || this.state.deviceMetrics === undefined) {
return null;
}
let deviceMetrics = [];
{
let states = this.state.deviceMetrics.getStatesMap();
let keys = states.toArray().map(v => v[0]);
keys.sort();
for (let i = 0; i < keys.length; i += 3) {
let items = keys.slice(i, i + 3).map(k => {
let m = states.get(k)!;
return (
<Col span={8}>
<Card>
<Statistic title={m.getName()} value={m.getValue()} />
</Card>
</Col>
);
});
deviceMetrics.push(<Row gutter={24}>{items}</Row>);
}
}
{
let metrics = this.state.deviceMetrics.getMetricsMap();
let keys = metrics.toArray().map(v => v[0]);
keys.sort();
for (let i = 0; i < keys.length; i += 3) {
let items = keys.slice(i, i + 3).map(k => {
let m = metrics.get(k)!;
return (
<Col span={8}>
<MetricChart metric={m} aggregation={this.state.metricsAggregation} zeroToNull />
</Col>
);
});
deviceMetrics.push(<Row gutter={24}>{items}</Row>);
}
}
let lastSeenAt = "Never";
if (this.props.lastSeenAt !== undefined) {
lastSeenAt = moment(this.props.lastSeenAt).format("YYYY-MM-DD HH:mm:ss");
}
const aggregations = (
<Space direction="horizontal">
<Radio.Group value={this.state.metricsAggregation} onChange={this.onMetricsAggregationChange} size="small">
<Radio.Button value={Aggregation.HOUR}>24h</Radio.Button>
<Radio.Button value={Aggregation.DAY}>31d</Radio.Button>
<Radio.Button value={Aggregation.MONTH}>1y</Radio.Button>
</Radio.Group>
<Button type="primary" size="small" icon={<ReloadOutlined />} onClick={this.loadMetrics} />
</Space>
);
return (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Card>
@ -337,42 +191,63 @@ class DeviceDashboard extends Component<IProps, IState> {
<Descriptions.Item label="Description">{this.props.device.getDescription()}</Descriptions.Item>
</Descriptions>
</Card>
<Row gutter={24}>
<Col span={12}>
<Card title="Received" className="dashboard-chart">
<Line height={75} options={statsOptions} data={this.state.statsUp} />
</Card>
</Col>
<Col span={12}>
<Card title="Errors" className="dashboard-chart">
<Bar data={this.state.statsErrors} options={barOptions} />
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Card title="SNR" className="dashboard-chart">
<Line height={75} options={statsOptions} data={this.state.statsGwSnr} />
</Card>
</Col>
<Col span={12}>
<Card title="RSSI" className="dashboard-chart">
<Line height={75} options={statsOptions} data={this.state.statsGwRssi} />
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Card title="Received / frequency" className="dashboard-chart">
<Heatmap data={this.state.statsUpFreq} fromColor="rgb(227, 242, 253)" toColor="rgb(33, 150, 243, 1)" />
</Card>
</Col>
<Col span={12}>
<Card title="Received / DR" className="dashboard-chart">
<Bar data={this.state.statsUpDr} options={barOptions} />
</Card>
</Col>
</Row>
<Tabs tabBarExtraContent={aggregations}>
<Tabs.TabPane tab="Link metrics" key="1">
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Row gutter={24}>
<Col span={8}>
<MetricChart
metric={this.state.deviceLinkMetrics.getRxPackets()!}
aggregation={this.state.metricsAggregation}
/>
</Col>
<Col span={8}>
<MetricChart
metric={this.state.deviceLinkMetrics.getGwRssi()!}
aggregation={this.state.metricsAggregation}
zeroToNull
/>
</Col>
<Col span={8}>
<MetricChart
metric={this.state.deviceLinkMetrics.getGwSnr()!}
aggregation={this.state.metricsAggregation}
zeroToNull
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={8}>
<MetricHeatmap
metric={this.state.deviceLinkMetrics.getRxPacketsPerFreq()!}
aggregation={this.state.metricsAggregation}
fromColor="rgb(227, 242, 253)"
toColor="rgb(33, 150, 243, 1)"
/>
</Col>
<Col span={8}>
<MetricHeatmap
metric={this.state.deviceLinkMetrics.getRxPacketsPerDr()!}
aggregation={this.state.metricsAggregation}
fromColor="rgb(227, 242, 253)"
toColor="rgb(33, 150, 243, 1)"
/>
</Col>
<Col span={8}>
<MetricBar
metric={this.state.deviceLinkMetrics.getErrors()!}
aggregation={this.state.metricsAggregation}
/>
</Col>
</Row>
</Space>
</Tabs.TabPane>
<Tabs.TabPane tab="Device metrics" key="2">
<Space direction="vertical" style={{ width: "100%" }} size="large">
{deviceMetrics}
</Space>
</Tabs.TabPane>
</Tabs>
</Space>
);
}

View File

@ -2,24 +2,20 @@ import React, { Component } from "react";
import moment from "moment";
import { Descriptions, Space, Card, Row, Col } from "antd";
import { TimeUnit } from "chart.js";
import { Line, Bar } from "react-chartjs-2";
import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb";
import {
Gateway,
GetGatewayStatsRequest,
GetGatewayStatsResponse,
GetGatewayMetricsRequest,
GetGatewayMetricsResponse,
} from "@chirpstack/chirpstack-api-grpc-web/api/gateway_pb";
import { Aggregation } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import GatewayStore from "../../stores/GatewayStore";
import Map, { Marker } from "../../components/Map";
import Heatmap from "../../components/Heatmap";
interface HeatmapStats {
x: string;
y: Array<[string, number]>;
}
import MetricChart from "../../components/MetricChart";
import MetricHeatmap from "../../components/MetricHeatmap";
import MetricBar from "../../components/MetricBar";
interface IProps {
gateway: Gateway;
@ -27,13 +23,8 @@ interface IProps {
}
interface IState {
statsUp?: any;
statsDown?: any;
statsUpFreq: HeatmapStats[];
statsDownFreq: HeatmapStats[];
statsUpDr: HeatmapStats[];
statsDownDr: HeatmapStats[];
statsDownStatus?: any;
metricsAggregation: Aggregation;
gatewayMetrics?: GetGatewayMetricsResponse;
}
class GatewayDashboard extends Component<IProps, IState> {
@ -41,187 +32,42 @@ class GatewayDashboard extends Component<IProps, IState> {
super(props);
this.state = {
statsUpFreq: [],
statsDownFreq: [],
statsUpDr: [],
statsDownDr: [],
metricsAggregation: Aggregation.DAY,
};
}
componentDidMount() {
this.loadStats();
this.loadMetrics();
}
loadStats = () => {
const end = moment().toDate();
const start = moment().subtract(30, "days").toDate();
loadMetrics = () => {
const agg = this.state.metricsAggregation;
const end = moment();
let start = moment();
if (agg === Aggregation.DAY) {
start = start.subtract(30, "days");
} else if (agg === Aggregation.HOUR) {
start = start.subtract(24, "hours");
} else if (agg === Aggregation.MONTH) {
start = start.subtract(12, "months");
}
let startPb = new Timestamp();
let endPb = new Timestamp();
startPb.fromDate(start);
endPb.fromDate(end);
startPb.fromDate(start.toDate());
endPb.fromDate(end.toDate());
let req = new GetGatewayStatsRequest();
let req = new GetGatewayMetricsRequest();
req.setGatewayId(this.props.gateway.getGatewayId());
req.setStart(startPb);
req.setEnd(endPb);
req.setAggregation(agg);
GatewayStore.getStats(req, (resp: GetGatewayStatsResponse) => {
let statsUp: {
labels: string[];
datasets: {
label: string;
borderColor: string;
backgroundColor: string;
lineTension: number;
pointBackgroundColor: string;
data: number[];
}[];
} = {
labels: [],
datasets: [
{
label: "rx received",
borderColor: "rgba(33, 150, 243, 1)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineTension: 0,
pointBackgroundColor: "rgba(33, 150, 243, 1)",
data: [],
},
],
};
let statsDown: {
labels: string[];
datasets: {
label: string;
borderColor: string;
backgroundColor: string;
lineTension: number;
pointBackgroundColor: string;
data: number[];
}[];
} = {
labels: [],
datasets: [
{
label: "rx received",
borderColor: "rgba(33, 150, 243, 1)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineTension: 0,
pointBackgroundColor: "rgba(33, 150, 243, 1)",
data: [],
},
],
};
let statsDownStatus: {
labels: string[];
datasets: {
label: string;
data: number[];
backgroundColor: string;
}[];
} = {
labels: [],
datasets: [],
};
let statsDownStatusSet: {
[key: string]: number[];
} = {};
let statsUpFreq: HeatmapStats[] = [];
let statsDownFreq: HeatmapStats[] = [];
let statsUpDr: HeatmapStats[] = [];
let statsDownDr: HeatmapStats[] = [];
for (const row of resp.getResultList()) {
statsUp.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsDown.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsDownStatus.labels.push(moment(row.getTime()!.toDate()).format("YYYY-MM-DD"));
statsUp.datasets[0].data.push(row.getRxPackets());
statsDown.datasets[0].data.push(row.getTxPackets());
statsUpFreq.push({
x: moment(row.getTime()!.toDate()).format("YYYY-MM-DD"),
y: row
.getRxPacketsPerFrequencyMap()
.toObject()
.map(v => [v[0].toString(), v[1]]),
});
statsDownFreq.push({
x: moment(row.getTime()!.toDate()).format("YYYY-MM-DD"),
y: row
.getTxPacketsPerFrequencyMap()
.toObject()
.map(v => [v[0].toString(), v[1]]),
});
statsUpDr.push({
x: moment(row.getTime()!.toDate()).format("YYYY-MM-DD"),
y: row
.getRxPacketsPerDrMap()
.toObject()
.map(v => [v[0].toString(), v[1]]),
});
statsDownDr.push({
x: moment(row.getTime()!.toDate()).format("YYYY-MM-DD"),
y: row
.getTxPacketsPerDrMap()
.toObject()
.map(v => [v[0].toString(), v[1]]),
});
for (const v of row.getTxPacketsPerStatusMap().toObject()) {
if (statsDownStatusSet[v[0]] === undefined) {
statsDownStatusSet[v[0]] = [];
}
// fill gaps with 0s
for (let i = statsDownStatusSet[v[0]].length; i < statsDownStatus.labels.length - 1; i++) {
statsDownStatusSet[v[0]].push(0);
}
statsDownStatusSet[v[0]].push(v[1]);
}
}
let backgroundColors = [
"#8bc34a",
"#ff5722",
"#ff9800",
"#ffc107",
"#ffeb3b",
"#cddc39",
"#4caf50",
"#009688",
"#00bcd4",
"#03a9f4",
"#2196f3",
"#3f51b5",
"#673ab7",
"#9c27b0",
"#e91e63",
];
Object.entries(statsDownStatusSet).forEach(([k, v]) => {
statsDownStatus.datasets.push({
label: k,
data: v,
backgroundColor: backgroundColors.shift()!,
});
});
GatewayStore.getMetrics(req, (resp: GetGatewayMetricsResponse) => {
this.setState({
statsUp: statsUp,
statsDown: statsDown,
statsUpFreq: statsUpFreq,
statsDownFreq: statsDownFreq,
statsUpDr: statsUpDr,
statsDownDr: statsDownDr,
statsDownStatus: statsDownStatus,
gatewayMetrics: resp,
});
});
};
@ -230,50 +76,7 @@ class GatewayDashboard extends Component<IProps, IState> {
const loc = this.props.gateway.getLocation()!;
const location: [number, number] = [loc.getLatitude(), loc.getLongitude()];
const animation: false = false;
const unit: TimeUnit = "day";
const barOptions = {
animation: animation,
plugins: {
legend: {
display: true,
},
},
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
},
x: {
time: {
unit: unit,
},
},
},
};
const statsOptions = {
animation: animation,
plugins: {
legend: {
display: false,
},
},
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
},
x: {
time: {
unit: unit,
},
},
},
};
if (this.state.statsUp === undefined) {
if (this.state.gatewayMetrics === undefined) {
return null;
}
@ -304,46 +107,59 @@ class GatewayDashboard extends Component<IProps, IState> {
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Card title="Received" className="dashboard-chart">
<Line height={75} options={statsOptions} data={this.state.statsUp} />
</Card>
<Col span={8}>
<MetricChart
metric={this.state.gatewayMetrics.getRxPackets()!}
aggregation={this.state.metricsAggregation}
/>
</Col>
<Col span={12}>
<Card title="Transmitted" className="dashboard-chart">
<Line height={75} options={statsOptions} data={this.state.statsDown} />
</Card>
<Col span={8}>
<MetricChart
metric={this.state.gatewayMetrics.getTxPackets()!}
aggregation={this.state.metricsAggregation}
/>
</Col>
<Col span={8}>
<MetricHeatmap
metric={this.state.gatewayMetrics.getRxPacketsPerFreq()!}
aggregation={this.state.metricsAggregation}
fromColor="rgb(227, 242, 253)"
toColor="rgb(33, 150, 243, 1)"
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Card title="Received / frequency" className="dashboard-chart">
<Heatmap data={this.state.statsUpFreq} fromColor="rgb(227, 242, 253)" toColor="rgb(33, 150, 243, 1)" />
</Card>
<Col span={8}>
<MetricHeatmap
metric={this.state.gatewayMetrics.getTxPacketsPerFreq()!}
aggregation={this.state.metricsAggregation}
fromColor="rgb(227, 242, 253)"
toColor="rgb(33, 150, 243, 1)"
/>
</Col>
<Col span={12}>
<Card title="Transmitted / frequency" className="dashboard-chart">
<Heatmap data={this.state.statsDownFreq} fromColor="rgb(227, 242, 253)" toColor="rgb(33, 150, 243, 1)" />
</Card>
<Col span={8}>
<MetricHeatmap
metric={this.state.gatewayMetrics.getRxPacketsPerDr()!}
aggregation={this.state.metricsAggregation}
fromColor="rgb(227, 242, 253)"
toColor="rgb(33, 150, 243, 1)"
/>
</Col>
<Col span={8}>
<MetricHeatmap
metric={this.state.gatewayMetrics.getTxPacketsPerDr()!}
aggregation={this.state.metricsAggregation}
fromColor="rgb(227, 242, 253)"
toColor="rgb(33, 150, 243, 1)"
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Card title="Received / DR" className="dashboard-chart">
<Heatmap data={this.state.statsUpDr} fromColor="rgb(227, 242, 253)" toColor="rgb(33, 150, 243, 1)" />
</Card>
</Col>
<Col span={12}>
<Card title="Transmitted / DR" className="dashboard-chart">
<Heatmap data={this.state.statsDownDr} fromColor="rgb(227, 242, 253)" toColor="rgb(33, 150, 243, 1)" />
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Card title="Transmission / Ack status" className="dashboard-chart">
<Bar data={this.state.statsDownStatus} options={barOptions} />
</Card>
<Col span={8}>
<MetricBar
metric={this.state.gatewayMetrics.getTxPacketsPerStatus()!}
aggregation={this.state.metricsAggregation}
/>
</Col>
</Row>
</Space>