diff --git a/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.html b/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.html
index 9a1424abe..907d2f86c 100644
--- a/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.html
+++ b/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.html
@@ -22,12 +22,12 @@
+ [type]="'line'" [title]="'IOTDEVICE.HISTORY-TAB.RSSI' | translate">
+ [type]="'line'" [title]="'IOTDEVICE.HISTORY-TAB.SNR' | translate">
diff --git a/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.ts b/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.ts
index e70572fbc..aa4fb7574 100644
--- a/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.ts
+++ b/src/app/applications/iot-devices/iot-device-detail/iot-device-detail.component.ts
@@ -18,20 +18,7 @@ import { MatTabChangeEvent } from '@angular/material/tabs';
import { ChartConfiguration } from 'chart.js';
import * as moment from 'moment';
import { recordToEntries } from '@shared/helpers/record.helper';
-
-const colorGraphBlue1 = '#03AEEF';
-
-const defaultChartOptions: ChartConfiguration['options'] = {
- plugins: { legend: { display: false }, },
- responsive: true,
- layout: {
- padding: {
- top: 15,
- left: 10,
- right: 10,
- }
- },
-};
+import { ColorGraphBlue1 } from '@shared/constants/color-constants';
/**
* Ordered from "worst" to "best" (from DR0 and up)
@@ -83,17 +70,13 @@ export class IoTDeviceDetailComponent implements OnInit, OnDestroy {
dataRateChartData: ChartConfiguration['data'] = { datasets: [] };
rssiChartData: ChartConfiguration['data'] = { datasets: [] };
snrChartData: ChartConfiguration['data'] = { datasets: [] };
- rssiChartOptions = defaultChartOptions;
- snrChartOptions: typeof defaultChartOptions = defaultChartOptions;
- dataRateChartOptions: typeof defaultChartOptions = {
- ...defaultChartOptions,
+ dataRateChartOptions: ChartConfiguration['options'] = {
scales: {
x: { stacked: true },
y: { stacked: true },
},
plugins: {
- ...defaultChartOptions,
tooltip: {
mode: 'index',
position: 'average',
@@ -226,7 +209,7 @@ export class IoTDeviceDetailComponent implements OnInit, OnDestroy {
this.iotDeviceService.getDeviceStats(this.deviceId).subscribe(
(response) => {
- if (response === null) {
+ if (!response) {
return;
}
@@ -250,10 +233,10 @@ export class IoTDeviceDetailComponent implements OnInit, OnDestroy {
},
{
rssiDatasets: [
- { data: [], borderColor: colorGraphBlue1, backgroundColor: colorGraphBlue1 },
+ { data: [], borderColor: ColorGraphBlue1, backgroundColor: ColorGraphBlue1 },
],
snrDatasets: [
- { data: [], borderColor: colorGraphBlue1, backgroundColor: colorGraphBlue1 },
+ { data: [], borderColor: ColorGraphBlue1, backgroundColor: ColorGraphBlue1 },
],
dataRateDatasets: this.initDataRates(),
labels: [],
diff --git a/src/app/gateway/enums/gateway-status-interval.enum.ts b/src/app/gateway/enums/gateway-status-interval.enum.ts
new file mode 100644
index 000000000..2e20a3d92
--- /dev/null
+++ b/src/app/gateway/enums/gateway-status-interval.enum.ts
@@ -0,0 +1,5 @@
+export enum GatewayStatusInterval {
+ DAY = 'DAY',
+ WEEK = 'WEEK',
+ MONTH = 'MONTH',
+}
diff --git a/src/app/gateway/gateway-detail/gateway-detail.component.html b/src/app/gateway/gateway-detail/gateway-detail.component.html
index f6981726c..0494dea12 100644
--- a/src/app/gateway/gateway-detail/gateway-detail.component.html
+++ b/src/app/gateway/gateway-detail/gateway-detail.component.html
@@ -46,36 +46,54 @@ {{ 'GATEWAY.LOCATION' | translate }}
-
{{ 'GATEWAY.STATS' | translate }}
-
-
-
-
-
-
- |
- {{ 'GATEWAY.STATS-RXPACKETSRECEIVED' | translate }}
- |
- {{element.rxPacketsReceived}} |
-
-
-
- {{ 'GATEWAY.STATS-TXPACKETSEMITTED' | translate }}
- |
- {{element.txPacketsEmitted}} |
-
+
+
+
-
- {{ 'GATEWAY.STATS-TIMESTAMP' | translate }} |
- {{element.timestamp | date}} |
-
+
+
{{ 'GATEWAY.DATA-PACKETS' | translate }}
+
+
+
-
|
-
|
-
-
-
+
+
+
+
+ |
+ {{ 'GATEWAY.STATS-RXPACKETSRECEIVED' | translate }}
+ |
+ {{element.rxPacketsReceived}} |
+
+
+
+ {{ 'GATEWAY.STATS-TXPACKETSEMITTED' | translate }}
+ |
+ {{element.txPacketsEmitted}} |
+
+
+
+ {{ 'GATEWAY.STATS-TIMESTAMP' | translate }} |
+ {{element.timestamp | date}} |
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/app/gateway/gateway-detail/gateway-detail.component.scss b/src/app/gateway/gateway-detail/gateway-detail.component.scss
index 00e4cb9fd..1922e7ffa 100644
--- a/src/app/gateway/gateway-detail/gateway-detail.component.scss
+++ b/src/app/gateway/gateway-detail/gateway-detail.component.scss
@@ -1,3 +1,3 @@
table {
- width: 100%;
- }
\ No newline at end of file
+ width: 100%;
+}
diff --git a/src/app/gateway/gateway-detail/gateway-detail.component.ts b/src/app/gateway/gateway-detail/gateway-detail.component.ts
index b9e750079..5195e80b6 100644
--- a/src/app/gateway/gateway-detail/gateway-detail.component.ts
+++ b/src/app/gateway/gateway-detail/gateway-detail.component.ts
@@ -1,16 +1,19 @@
import { AfterViewInit, Component, EventEmitter, OnDestroy, OnInit, ViewChild } from '@angular/core';
-import { Subscription } from 'rxjs';
+import { Subscription, Subject } from 'rxjs';
import { ChirpstackGatewayService } from 'src/app/shared/services/chirpstack-gateway.service';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { BackButton } from '@shared/models/back-button.model';
-import { Gateway, GatewayStats } from '../gateway.model';
+import { Gateway, GatewayStats, GatewayResponse } from '../gateway.model';
import { DeleteDialogService } from '@shared/components/delete-dialog/delete-dialog.service';
import { MeService } from '@shared/services/me.service';
import { environment } from '@environments/environment';
import { DropdownButton } from '@shared/models/dropdown-button.model';
+import { ChartConfiguration } from 'chart.js';
+import { ColorGraphBlue1 } from '@shared/constants/color-constants';
+import { formatDate } from '@angular/common';
@Component({
selector: 'app-gateway-detail',
@@ -29,11 +32,14 @@ export class GatewayDetailComponent implements OnInit, OnDestroy, AfterViewInit
public gatewaySubscription: Subscription;
public gateway: Gateway;
public backButton: BackButton = { label: '', routerLink: ['gateways'] };
- private id: string;
+ id: string;
deleteGateway = new EventEmitter();
private deleteDialogSubscription: Subscription;
public dropdownButton: DropdownButton;
isLoadingResults = true;
+ isGatewayStatusVisibleSubject = new Subject();
+ receivedGraphData: ChartConfiguration['data'] = { datasets: [] };
+ sentGraphData: ChartConfiguration['data'] = { datasets: [] };
constructor(
private gatewayService: ChirpstackGatewayService,
@@ -72,7 +78,7 @@ export class GatewayDetailComponent implements OnInit, OnDestroy, AfterViewInit
}
bindGateway(id: string): void {
- this.gatewayService.get(id).subscribe((result: any) => {
+ this.gatewayService.get(id).subscribe((result: GatewayResponse) => {
result.gateway.tagsString = JSON.stringify(result.gateway.tags);
this.gateway = result.gateway;
this.gateway.canEdit = this.canEdit();
@@ -83,6 +89,9 @@ export class GatewayDetailComponent implements OnInit, OnDestroy, AfterViewInit
this.dataSource.paginator = this.paginator;
this.setDropdownButton();
this.isLoadingResults = false;
+
+ this.buildGraphs();
+ this.isGatewayStatusVisibleSubject.next();
});
}
@@ -94,11 +103,44 @@ export class GatewayDetailComponent implements OnInit, OnDestroy, AfterViewInit
} : null;
this.translate.get(['LORA-GATEWAY-TABLE-ROW.SHOW-OPTIONS'])
.subscribe(translations => {
- this.dropdownButton.label = translations['LORA-GATEWAY-TABLE-ROW.SHOW-OPTIONS']
+ this.dropdownButton.label = translations['LORA-GATEWAY-TABLE-ROW.SHOW-OPTIONS'];
}
);
}
+ private buildGraphs() {
+ const { receivedDatasets, sentDatasets, labels } = this.gatewayStats.reduce(
+ (
+ res: {
+ receivedDatasets: ChartConfiguration['data']['datasets'];
+ sentDatasets: ChartConfiguration['data']['datasets'];
+ labels: ChartConfiguration['data']['labels'];
+ },
+ data
+ ) => {
+ res.receivedDatasets[0].data.push(data.rxPacketsReceived);
+ res.sentDatasets[0].data.push(data.txPacketsEmitted);
+
+ // Formatted to stay consistent with the corresponding table. When more languages are added,
+ // register and use them properly. See https://stackoverflow.com/a/54769064
+ res.labels.push(formatDate(data.timestamp, 'dd MMM', 'en-US'));
+ return res;
+ },
+ {
+ receivedDatasets: [
+ { data: [], borderColor: ColorGraphBlue1, backgroundColor: ColorGraphBlue1 },
+ ],
+ sentDatasets: [
+ { data: [], borderColor: ColorGraphBlue1, backgroundColor: ColorGraphBlue1 },
+ ],
+ labels: [],
+ }
+ );
+
+ this.receivedGraphData = { datasets: receivedDatasets, labels };
+ this.sentGraphData = { datasets: sentDatasets, labels };
+ }
+
canEdit(): boolean {
return this.meService.canWriteInTargetOrganization(this.gateway.internalOrganizationId);
}
diff --git a/src/app/gateway/gateway-list/gateway-list.component.html b/src/app/gateway/gateway-list/gateway-list.component.html
index 8c92e32f7..cc9cffc72 100644
--- a/src/app/gateway/gateway-list/gateway-list.component.html
+++ b/src/app/gateway/gateway-list/gateway-list.component.html
@@ -7,7 +7,7 @@
[dropdownLabel]="'GATEWAY.DROPDOWNFILTER' | translate" (updateSelectedOpt)="setOrgIdFilter($event)"
[dropdownDefaultOption]="'GATEWAY.DROPDOWNDEFAULT' | translate">
-
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/app/gateway/gateway-list/gateway-list.component.ts b/src/app/gateway/gateway-list/gateway-list.component.ts
index d022dd4fe..061a6d81a 100644
--- a/src/app/gateway/gateway-list/gateway-list.component.ts
+++ b/src/app/gateway/gateway-list/gateway-list.component.ts
@@ -11,7 +11,9 @@ import { MeService } from '@shared/services/me.service';
import { SharedVariableService } from '@shared/shared-variable/shared-variable.service';
import { environment } from '@environments/environment';
import { Title } from '@angular/platform-browser';
+import { MatTabChangeEvent } from '@angular/material/tabs';
+const gatewayStatusTabIndex = 2;
@Component({
selector: 'app-gateway-list',
@@ -41,7 +43,9 @@ export class GatewayListComponent implements OnInit, OnChanges, OnDestroy {
public pageOffset = 0;
public pageTotal: number;
organisationId: number;
- organisationChangeSubject: Subject = new Subject();
+ tabIndex = 0;
+ organisationChangeSubject: Subject = new Subject();
+ isGatewayStatusVisibleSubject: Subject = new Subject();
constructor(
public translate: TranslateService,
@@ -49,7 +53,8 @@ export class GatewayListComponent implements OnInit, OnChanges, OnDestroy {
private deleteDialogService: DeleteDialogService,
private meService: MeService,
private titleService: Title,
- private sharedVariableService: SharedVariableService) {
+ private sharedVariableService: SharedVariableService,
+ ) {
translate.use('da');
moment.locale('da');
}
@@ -75,10 +80,15 @@ export class GatewayListComponent implements OnInit, OnChanges, OnDestroy {
}
}
- setOrgIdFilter(event: number) {
- this.organisationId = event;
- this.organisationChangeSubject.next(event);
- this.filterGatewayByOrgId(event);
+ setOrgIdFilter(orgId: number) {
+ this.organisationId = orgId;
+ this.organisationChangeSubject.next(orgId);
+
+ if (this.tabIndex === gatewayStatusTabIndex) {
+ this.isGatewayStatusVisibleSubject.next();
+ }
+
+ this.filterGatewayByOrgId(orgId);
}
private getGateways(): void {
@@ -120,14 +130,18 @@ export class GatewayListComponent implements OnInit, OnChanges, OnDestroy {
);
}
- showMap(event: any) {
- if (event.index === 1) {
+ selectedTabChange({index}: MatTabChangeEvent) {
+ this.tabIndex = index;
+
+ if (index === 1) {
if (this.selectedOrg) {
this.getGatewayWith(this.selectedOrg);
} else {
this.getGateways();
}
this.showmap = true;
+ } else if (index === gatewayStatusTabIndex) {
+ this.isGatewayStatusVisibleSubject.next();
}
}
@@ -184,12 +198,7 @@ export class GatewayListComponent implements OnInit, OnChanges, OnDestroy {
ngOnDestroy() {
// prevent memory leak by unsubscribing
- if (this.gatewaySubscription) {
- this.gatewaySubscription.unsubscribe();
- }
- if (this.deleteDialogSubscription) {
- this.deleteDialogSubscription.unsubscribe();
- }
+ this.gatewaySubscription?.unsubscribe();
+ this.deleteDialogSubscription?.unsubscribe();
}
-
}
diff --git a/src/app/gateway/gateway-status/gateway-status.component.html b/src/app/gateway/gateway-status/gateway-status.component.html
new file mode 100644
index 000000000..81ce42d60
--- /dev/null
+++ b/src/app/gateway/gateway-status/gateway-status.component.html
@@ -0,0 +1,67 @@
+
+
+ {{ 'GEN.NO-DATA' | translate }}
+
+
+
+
+
{{title}}
+
+
+ {{ 'LORA-GATEWAY-STATUS.INTERVAL.' + interval | translate }}
+
+
+
+
+
+
+
+
+
+ |
+ {{element.name}}
+ {{element.name}}
+ |
+
+ {{'GEN.DATE' | translate}}
+
+ {{'LORA-GATEWAY-STATUS.TIMESTAMP' | translate}}
+ |
+
+
+
+ |
+
+ {{time.datePart}}
+
+ {{time.timePart}}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
{{ "GEN.ONLINE" | translate}}
+
+
+
+
{{ "GEN.OFFLINE" | translate}}
+
+
+
+
{{ "GEN.NEVER-SEEN" | translate}}
+
+
diff --git a/src/app/gateway/gateway-status/gateway-status.component.scss b/src/app/gateway/gateway-status/gateway-status.component.scss
new file mode 100644
index 000000000..18ded2fbf
--- /dev/null
+++ b/src/app/gateway/gateway-status/gateway-status.component.scss
@@ -0,0 +1,105 @@
+@import '~src/assets/scss/setup/variables';
+
+$online: #56b257;
+$offline: #e74c3c;
+$neverSeen: #acacac;
+$cellFontSize: 0.9rem;
+
+.status-table {
+ border-collapse: separate;
+ border-spacing: 10px 4px;
+
+ .online {
+ background-color: $online;
+ }
+ .offline {
+ background-color: $offline;
+ }
+ .never-seen {
+ background-color: $neverSeen;
+ }
+
+ tr {
+ &:hover {
+ background-color: unset;
+ }
+
+ > td {
+ font-size: $cellFontSize;
+ &:hover {
+ transition: $transition-short;
+ filter: brightness(0.65);
+ }
+
+ &.mat-cell {
+ min-width: 1.2rem;
+
+ &:not(:first-child) {
+ max-width: 1.2rem;
+ border-radius: 3px;
+ }
+
+ &:first-child {
+ padding-left: 0;
+ min-width: 5rem;
+ }
+
+ &:last-child {
+ max-width: 1.2rem;
+ padding-right: 0;
+ }
+ }
+
+ &.mat-footer-cell {
+ &:first-child {
+ padding-left: 0;
+ }
+
+ &:last-child {
+ padding-right: 0;
+ }
+ }
+ }
+ }
+}
+
+.status-legend {
+ .legend {
+ width: 5rem;
+ height: 8px;
+
+ &.online {
+ background-color: $online;
+ }
+ &.offline {
+ background-color: $offline;
+ }
+ &.never-seen {
+ background-color: $neverSeen;
+ }
+ }
+
+ span {
+ color: rgba($color: #000, $alpha: 0.6);
+ font-size: $cellFontSize;
+ }
+}
+
+.overflow-auto {
+ overflow: auto;
+}
+
+.no-shadow {
+ box-shadow: unset;
+ -webkit-box-shadow: unset;
+ -moz-box-shadow: unset;
+}
+
+.status-interval {
+ width: 180px;
+ font-size: $cellFontSize;
+}
+
+.title {
+ margin-bottom: 0 !important;
+}
diff --git a/src/app/gateway/gateway-status/gateway-status.component.ts b/src/app/gateway/gateway-status/gateway-status.component.ts
new file mode 100644
index 000000000..f569027db
--- /dev/null
+++ b/src/app/gateway/gateway-status/gateway-status.component.ts
@@ -0,0 +1,280 @@
+import {
+ AfterContentInit,
+ Component,
+ Input,
+ OnDestroy,
+ ViewChild,
+} from '@angular/core';
+import { MatOptionSelectionChange } from '@angular/material/core';
+import { MatPaginator } from '@angular/material/paginator';
+import { MatTableDataSource } from '@angular/material/table';
+import { environment } from '@environments/environment';
+import { TranslateService } from '@ngx-translate/core';
+import { recordToEntries } from '@shared/helpers/record.helper';
+import { LoRaWANGatewayService } from '@shared/services/lorawan-gateway.service';
+import * as moment from 'moment';
+import { Observable, Subject, Subscription } from 'rxjs';
+import { GatewayStatusInterval } from '../enums/gateway-status-interval.enum';
+import { GatewayStatus, AllGatewayStatusResponse } from '../gateway.model';
+import { map } from 'rxjs/operators';
+
+interface TimeColumn {
+ exactTimestamp: string;
+ tooltip: string;
+ datePart: string;
+ timePart: string;
+}
+
+@Component({
+ selector: 'app-gateway-status',
+ templateUrl: './gateway-status.component.html',
+ styleUrls: ['./gateway-status.component.scss'],
+})
+export class GatewayStatusComponent implements AfterContentInit, OnDestroy {
+ @Input() organisationChangeSubject: Subject;
+ @Input() isVisibleSubject: Subject;
+ @Input() paginatorClass: string;
+ @Input() title: string;
+ @Input() gatewayId: string;
+ @Input() shouldLinkToDetails = true;
+
+ private gatewayStatusSubscription: Subscription;
+ private readonly columnGatewayName = 'gatewayName';
+ dataSource: MatTableDataSource;
+ /**
+ * List of pre-processed timestamps for performance
+ */
+ timeColumns: TimeColumn[] = [];
+ displayedColumns: (TimeColumn | string)[] = [];
+ nameText = '';
+ neverSeenText = '';
+ timestampText = '';
+ visibleFooterTimeInterval = 1;
+ pageSize = environment.tablePageSize;
+ resultsLength = 0;
+ organizationId: number | undefined;
+ isLoadingResults = false;
+ isDirty = true;
+ statusIntervals: GatewayStatusInterval[];
+ selectedStatusInterval = GatewayStatusInterval.DAY;
+
+ @ViewChild(MatPaginator) paginator: MatPaginator;
+
+ constructor(
+ private translate: TranslateService,
+ private lorawanGatewayService: LoRaWANGatewayService
+ ) {}
+
+ ngAfterContentInit(): void {
+ this.translate
+ .get(['GEN.NAME', 'GEN.NEVER-SEEN', 'LORA-GATEWAY-STATUS.TIMESTAMP'])
+ .subscribe((translations) => {
+ this.nameText = translations['GEN.NAME'];
+ this.neverSeenText = translations['GEN.NEVER-SEEN'];
+ this.timestampText = translations['LORA-GATEWAY-STATUS.TIMESTAMP'];
+ });
+
+ this.organisationChangeSubject?.subscribe((orgId) => {
+ if (this.organizationId !== orgId) {
+ this.organizationId = orgId;
+ this.isDirty = true;
+ }
+ });
+
+ this.isVisibleSubject?.pipe().subscribe(() => {
+ if (!this.isDirty) {
+ return;
+ }
+
+ this.isDirty = false;
+ this.subscribeToGetAllGatewayStatus();
+ });
+
+ this.statusIntervals = recordToEntries(GatewayStatusInterval).map(
+ (interval) => interval.value
+ );
+ }
+
+ private getGatewayStatus(
+ organizationId = this.organizationId,
+ timeInterval = this.selectedStatusInterval
+ ): Observable {
+ const params: Record = {
+ timeInterval,
+ // Paginator is only avaiable in ngAfterViewInit
+ limit: this.paginator?.pageSize,
+ offset: this.paginator?.pageIndex * this.paginator.pageSize,
+ };
+
+ if (organizationId) {
+ params.organizationId = organizationId;
+ }
+
+ return !this.gatewayId
+ ? this.lorawanGatewayService.getAllStatus(params)
+ : this.lorawanGatewayService
+ .getStatus(this.gatewayId, { timeInterval })
+ .pipe(
+ map(
+ (response) =>
+ ({ data: [response], count: 1 } as AllGatewayStatusResponse)
+ )
+ );
+ }
+
+ private subscribeToGetAllGatewayStatus(
+ organizationId = this.organizationId,
+ timeInterval = this.selectedStatusInterval
+ ): void {
+ this.isLoadingResults = true;
+ this.paginator.pageIndex = 0;
+ this.gatewayStatusSubscription = this.getGatewayStatus(
+ organizationId,
+ timeInterval
+ ).subscribe((response) => {
+ this.isLoadingResults = false;
+
+ if (response) {
+ this.handleStatusResponse(response);
+ }
+ });
+ }
+
+ private handleStatusResponse(response: AllGatewayStatusResponse) {
+ this.resultsLength = response.count;
+ const gatewaysWithLatestTimestampsPerHour = this.takeLatestTimestampInHour(
+ response.data
+ );
+ const gatewaysWithWholeHourTimestamps = this.toWholeHour(
+ gatewaysWithLatestTimestampsPerHour
+ );
+
+ const sortedData = gatewaysWithWholeHourTimestamps
+ .slice()
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ this.buildColumns(sortedData);
+ this.visibleFooterTimeInterval = Math.round(
+ this.clamp(this.timeColumns.length / 4, 1, 6)
+ );
+
+ this.dataSource = new MatTableDataSource(sortedData);
+ this.dataSource.paginator = this.paginator;
+ }
+
+ private buildColumns(response: GatewayStatus[]) {
+ let minDate: Date | null | undefined;
+ let maxDate: Date | null | undefined;
+ this.timeColumns = [];
+
+ response.forEach((gateway) => {
+ gateway.statusTimestamps.forEach(({ timestamp }) => {
+ if (!minDate) {
+ minDate = timestamp;
+ }
+ if (!maxDate) {
+ maxDate = timestamp;
+ }
+
+ if (timestamp < minDate) {
+ minDate = timestamp;
+ } else if (timestamp > maxDate) {
+ maxDate = timestamp;
+ }
+ });
+ });
+
+ if (minDate && maxDate) {
+ const currDate = moment(minDate).startOf('hour');
+ const lastDate = moment(maxDate).startOf('hour');
+
+ do {
+ this.timeColumns.push({
+ exactTimestamp: currDate.toISOString(),
+ tooltip: currDate.format('DD-MM-YYYY HH:00'),
+ datePart: currDate.format('DD-MM'),
+ timePart: currDate.format('HH:00'),
+ });
+ } while (currDate.add(1, 'hour').startOf('hour').diff(lastDate) <= 0);
+ }
+
+ this.displayedColumns = [
+ this.columnGatewayName,
+ ...this.timeColumns.map((column) => column.exactTimestamp),
+ ];
+ }
+
+ private toWholeHour(data: GatewayStatus[]): typeof data {
+ return data.map((gateway) => ({
+ ...gateway,
+ statusTimestamps: gateway.statusTimestamps.map((status) => ({
+ ...status,
+ timestamp: moment(status.timestamp).startOf('hour').toDate(),
+ })),
+ }));
+ }
+
+ /**
+ * The most recent status per time period takes priority. The data can contain multiple data points
+ * per time period. This method processeses the data and keeps the latest data point per time period.
+ *
+ * @param data A list of gateway status'
+ */
+ private takeLatestTimestampInHour(data: GatewayStatus[]): typeof data {
+ return data.map((gateway) => {
+ const timestamps = gateway.statusTimestamps.reduce(
+ (res: typeof gateway.statusTimestamps, currentStatus) => {
+ // Check if we already passed a timestamp in the same hour slot as the current one and if it's older
+ const currentTimestamp = moment(currentStatus.timestamp);
+ const sameHourTimestampIndex = res.findIndex((storedStatus) => {
+ const storedTimestamp = moment(storedStatus.timestamp);
+ return storedTimestamp.isSame(currentTimestamp, 'hour');
+ });
+
+ if (sameHourTimestampIndex >= 0) {
+ // Only keep the latest timestamp in the same slot
+ if (
+ res[sameHourTimestampIndex].timestamp < currentStatus.timestamp
+ ) {
+ res.splice(sameHourTimestampIndex, 1);
+ } else {
+ // Don't store the current timestamp as it's older than the stored one
+ return res;
+ }
+ }
+
+ res.push(currentStatus);
+ return res;
+ },
+ []
+ );
+
+ return {
+ ...gateway,
+ statusTimestamps: timestamps,
+ };
+ });
+ }
+
+ private clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ onSelectInterval({
+ isUserInput,
+ source: { value: newInterval },
+ }: MatOptionSelectionChange) {
+ if (
+ isUserInput &&
+ newInterval !== this.selectedStatusInterval &&
+ !this.isLoadingResults
+ ) {
+ this.subscribeToGetAllGatewayStatus(this.organizationId, newInterval);
+ }
+ }
+
+ ngOnDestroy() {
+ // prevent memory leak by unsubscribing
+ this.gatewayStatusSubscription?.unsubscribe();
+ }
+}
diff --git a/src/app/gateway/gateway.model.ts b/src/app/gateway/gateway.model.ts
index dc8d09813..56efa7d94 100644
--- a/src/app/gateway/gateway.model.ts
+++ b/src/app/gateway/gateway.model.ts
@@ -1,5 +1,6 @@
import { EditPermission } from '@shared/models/edit-permission.model';
import { CommonLocation } from '../shared/models/common-location.model';
+import { GatewayStatusInterval } from './enums/gateway-status-interval.enum';
export class Gateway extends EditPermission {
id?: string;
@@ -57,3 +58,30 @@ export interface GatewayStats {
txPacketsReceived: number;
txPacketsEmitted: number;
}
+
+export interface GetAllGatewayStatusParameters {
+ limit?: number;
+ offset?: number;
+ organizationId?: number;
+ timeInterval?: GatewayStatusInterval;
+}
+
+export interface StatusTimestamp {
+ timestamp: Date;
+ wasOnline: boolean;
+}
+
+export interface GetGatewayStatusParameters {
+ timeInterval?: GatewayStatusInterval;
+}
+
+export interface GatewayStatus {
+ id: string;
+ name: string;
+ statusTimestamps: StatusTimestamp[];
+}
+
+export interface AllGatewayStatusResponse {
+ data: GatewayStatus[];
+ count: number;
+}
diff --git a/src/app/gateway/gateway.module.ts b/src/app/gateway/gateway.module.ts
index a538ce239..4f6c01562 100644
--- a/src/app/gateway/gateway.module.ts
+++ b/src/app/gateway/gateway.module.ts
@@ -13,6 +13,8 @@ import { NGMaterialModule } from '@shared/Modules/materiale.module';
import { FormModule } from '@shared/components/forms/form.module';
import { SharedModule } from '@shared/shared.module';
import { PipesModule } from '@shared/pipes/pipes.module';
+import { GatewayStatusComponent } from './gateway-status/gateway-status.component';
+import { GraphModule } from '@app/graph/graph.module';
const gatewayRoutes: Routes = [
{
@@ -35,6 +37,7 @@ const gatewayRoutes: Routes = [
GatewayListComponent,
GatewayDetailComponent,
GatewayEditComponent,
+ GatewayStatusComponent,
],
imports: [
CommonModule,
@@ -47,12 +50,14 @@ const gatewayRoutes: Routes = [
RouterModule.forChild(gatewayRoutes),
SharedModule,
PipesModule,
+ GraphModule,
],
exports: [
GatewayTableComponent,
GatewaysComponent,
GatewayListComponent,
GatewayEditComponent,
+ GatewayStatusComponent,
RouterModule
]
})
diff --git a/src/app/graph/graph.component.html b/src/app/graph/graph.component.html
index a7ac6b3ab..1c8030cf3 100644
--- a/src/app/graph/graph.component.html
+++ b/src/app/graph/graph.component.html
@@ -1,12 +1,12 @@
-
-
+
+
{{title}}
-
{{ 'GRAPH.NO-DATA' | translate }}
+ {{ 'GEN.NO-DATA' | translate }}
diff --git a/src/app/graph/graph.component.ts b/src/app/graph/graph.component.ts
index 2710eca4b..e1d0d478d 100644
--- a/src/app/graph/graph.component.ts
+++ b/src/app/graph/graph.component.ts
@@ -19,9 +19,19 @@ export class GraphComponent implements OnChanges {
@Input() data: ChartConfiguration['data'];
@Input() type: ChartConfiguration['type'];
@Input() options: ChartConfiguration['options'] = {
+ plugins: { legend: { display: false } },
responsive: true,
+ layout: {
+ padding: {
+ top: 15,
+ left: 10,
+ right: 10,
+ },
+ },
};
@Input() title: string;
+ @Input() graphCardClass: string;
+ @Input() graphHeaderClass: string;
chartInstance: Chart = null;
isGraphEmpty: boolean;
@@ -72,13 +82,13 @@ export class GraphComponent implements OnChanges {
? {
...options,
scales: {
- ...options.scales,
+ ...options?.scales,
x: {
- ...options.scales.x,
+ ...options?.scales?.x,
display: false,
},
y: {
- ...options.scales.y,
+ ...options?.scales?.y,
display: false,
},
},
diff --git a/src/app/shared/constants/color-constants.ts b/src/app/shared/constants/color-constants.ts
new file mode 100644
index 000000000..19b9b6c5a
--- /dev/null
+++ b/src/app/shared/constants/color-constants.ts
@@ -0,0 +1 @@
+export const ColorGraphBlue1 = '#03AEEF';
diff --git a/src/app/shared/pipes/gateway/gateway-status-class.pipe.ts b/src/app/shared/pipes/gateway/gateway-status-class.pipe.ts
new file mode 100644
index 000000000..863e62fd3
--- /dev/null
+++ b/src/app/shared/pipes/gateway/gateway-status-class.pipe.ts
@@ -0,0 +1,27 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { StatusTimestamp } from '@app/gateway/gateway.model';
+
+@Pipe({
+ name: 'gatewayStatusClass',
+})
+/**
+ * Separate pipe to format text to avoid renders if none of the values
+ * have changed.
+ */
+export class GatewayStatusClassPipe implements PipeTransform {
+ transform(
+ statusTimestamps: StatusTimestamp[],
+ timestamp: string,
+ ..._: unknown[]
+ ): string {
+ return !statusTimestamps.length
+ ? 'never-seen'
+ : statusTimestamps.some(
+ (gatewayTimestamp) =>
+ gatewayTimestamp.timestamp.toISOString() === timestamp &&
+ gatewayTimestamp.wasOnline
+ )
+ ? 'online'
+ : 'offline';
+ }
+}
diff --git a/src/app/shared/pipes/gateway/gateway-status-tooltip.pipe.ts b/src/app/shared/pipes/gateway/gateway-status-tooltip.pipe.ts
new file mode 100644
index 000000000..cbc272ffe
--- /dev/null
+++ b/src/app/shared/pipes/gateway/gateway-status-tooltip.pipe.ts
@@ -0,0 +1,23 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'gatewayStatusTooltip',
+})
+/**
+ * Separate pipe to format text to avoid renders if none of the values
+ * have changed.
+ */
+export class GatewayStatusTooltipPipe implements PipeTransform {
+ transform(
+ hasBeenSeen: boolean,
+ neverSeenText: string,
+ timestampLabel: string,
+ timestamp: string,
+ nameLabel: string,
+ name: string,
+ ..._: unknown[]
+ ): string {
+ const formattedTime = !hasBeenSeen ? neverSeenText : timestamp;
+ return `${nameLabel}: ${name}\n${timestampLabel}: ${formattedTime}`;
+ }
+}
diff --git a/src/app/shared/pipes/pipes.module.ts b/src/app/shared/pipes/pipes.module.ts
index 7d9119436..ea2159ad6 100644
--- a/src/app/shared/pipes/pipes.module.ts
+++ b/src/app/shared/pipes/pipes.module.ts
@@ -7,6 +7,8 @@ import { CreatedUpdatedByPipe } from './created-updated-by.pipe';
import { CustomDatePipe, CustomTableDatePipe, DateOnlyPipe } from './custom-date.pipe';
import { FilterDevicesPipe } from './filter-devices.pipe';
import { SortByPipe } from './sort-by.pipe';
+import { GatewayStatusTooltipPipe } from './gateway/gateway-status-tooltip.pipe';
+import { GatewayStatusClassPipe } from './gateway/gateway-status-class.pipe';
@NgModule({
declarations: [
@@ -18,7 +20,9 @@ import { SortByPipe } from './sort-by.pipe';
DateOnlyPipe,
CreatedUpdatedByPipe,
FilterDevicesPipe,
- SortByPipe
+ SortByPipe,
+ GatewayStatusTooltipPipe,
+ GatewayStatusClassPipe
],
imports: [CommonModule],
exports: [
@@ -30,7 +34,9 @@ import { SortByPipe } from './sort-by.pipe';
DateOnlyPipe,
CreatedUpdatedByPipe,
FilterDevicesPipe,
- SortByPipe
+ SortByPipe,
+ GatewayStatusTooltipPipe,
+ GatewayStatusClassPipe
],
providers: [
DateOnlyPipe
diff --git a/src/app/shared/services/lorawan-gateway.service.ts b/src/app/shared/services/lorawan-gateway.service.ts
new file mode 100644
index 000000000..21b0afb87
--- /dev/null
+++ b/src/app/shared/services/lorawan-gateway.service.ts
@@ -0,0 +1,35 @@
+import { Injectable } from '@angular/core';
+import {
+ AllGatewayStatusResponse,
+ GetAllGatewayStatusParameters,
+ GetGatewayStatusParameters,
+ GatewayStatus,
+} from '@app/gateway/gateway.model';
+import { Observable } from 'rxjs';
+import { RestService } from './rest.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class LoRaWANGatewayService {
+ private baseUrl = 'lorawan/gateway';
+
+ constructor(private restService: RestService) {}
+
+ public getAllStatus(
+ params: GetAllGatewayStatusParameters
+ ): Observable {
+ return this.restService.get(`${this.baseUrl}/status`, params);
+ }
+
+ public getStatus(
+ id: string,
+ params: GetGatewayStatusParameters
+ ): Observable {
+ return this.restService.get(
+ `${this.baseUrl}/status`,
+ params,
+ id
+ );
+ }
+}
diff --git a/src/assets/i18n/da.json b/src/assets/i18n/da.json
index 273a64c6f..1a2708dc0 100644
--- a/src/assets/i18n/da.json
+++ b/src/assets/i18n/da.json
@@ -13,7 +13,12 @@
"VALUE": "Værdi",
"NAME": "Navn",
"DESCRIPTION": "Beskrivelse",
- "to": "til"
+ "to": "til",
+ "ONLINE": "Online",
+ "OFFLINE": "Offline",
+ "NEVER-SEEN": "Aldrig set",
+ "DATE": "Dato",
+ "NO-DATA": "Der er ingen data at vise"
},
"NAV": {
"APPLICATIONS": "Applikationer",
@@ -160,13 +165,15 @@
"NAME": "Gatewayens navn",
"STATS": "Statistik",
"STATS-RXPACKETSRECEIVED": "Pakker modtaget",
- "STATS-TXPACKETSEMITTED": "Pakker Sendt",
+ "STATS-TXPACKETSEMITTED": "Pakker sendt",
"STATS-TIMESTAMP": "Tidspunkt",
"TABEL-TAB": "Listevisning",
"DROPDOWNFILTER": "Organisationsfilter",
"DROPDOWNDEFAULT": "Alle",
"MAP-TAB": "Kort",
- "ORGANIZATION": "Organisation"
+ "ORGANIZATION": "Organisation",
+ "ONLINE-STATUS": "Online status",
+ "DATA-PACKETS": "Datapakker"
},
"IOT-DEVICE": {
"CREATE": "Gem IoT enhed",
@@ -301,6 +308,15 @@
"STATUS": "Status",
"ORGANIZATION": "Organisation"
},
+ "LORA-GATEWAY-STATUS": {
+ "TITLE": "LoRaWAN online status historik",
+ "TIMESTAMP": "Tidspunkt",
+ "INTERVAL": {
+ "DAY": "Seneste døgn",
+ "WEEK": "Seneste uge",
+ "MONTH": "Seneste måned"
+ }
+ },
"DEVICE-MODEL": {
"DELETE-FAILED":"Slet Fejlede",
"HEADLINE":"Detaljer",
@@ -1069,8 +1085,5 @@
"GIVE-DATATARGET-CONTEXT-INFO": "hvis tom, skal den angives i 'payload'",
"GIVE-DATATARGET-CONTEXT-PLACEHOLDER": "https://os2iot/context-file.json"
}
- },
- "GRAPH": {
- "NO-DATA": "Der er ingen data at vise"
}
}
diff --git a/src/assets/scss/setup/_variables.scss b/src/assets/scss/setup/_variables.scss
index 4486497fe..52f3f12a5 100644
--- a/src/assets/scss/setup/_variables.scss
+++ b/src/assets/scss/setup/_variables.scss
@@ -152,7 +152,7 @@ $color-blue-004: rgba(63, 192, 243, 0.164) !default;
$color-green-001: rgba(86, 178, 87, 1) !default;
$color-green-002: rgba(86, 178, 87, 0.75) !default;
-$color-green-003: rgba(486, 178, 87, 0.5) !default;
+$color-green-003: rgba(86, 178, 87, 0.5) !default;
$color-green-004: rgba(86, 178, 87, 0.25) !default;
$color-dark-green-001: rgba(0, 49, 39, 1) !default;
@@ -381,4 +381,4 @@ $red: #dc3545;
.mat-tab-body.mat-tab-body-active {
overflow-y: hidden;
}
-}
\ No newline at end of file
+}
diff --git a/src/styles.scss b/src/styles.scss
index 1a1598761..7c28ffbd2 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -89,6 +89,15 @@ body {
font-size: 1rem !important;
}
+.status-tooltip {
+ background: rgba(97, 97, 97, 1.0);
+ white-space: pre-line;
+}
+
+.mat-card-header-text-ml-0 .mat-card-header-text {
+ margin-left: 0;
+}
+
.welcome-screen .mat-dialog-container {
background-color: #55b156;
}