mirror of
https://github.com/chirpstack/chirpstack.git
synced 2025-06-16 06:18:27 +00:00
Implement support for quick device measurement metrics.
This commit is contained in:
98
ui/src/components/MetricBar.tsx
Normal file
98
ui/src/components/MetricBar.tsx
Normal 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;
|
81
ui/src/components/MetricChart.tsx
Normal file
81
ui/src/components/MetricChart.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user