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.DATA-PACKETS' | translate }}

+
+ +
-
- -
- {{ 'GATEWAY.STATS-RXPACKETSRECEIVED' | translate }} - {{element.rxPacketsReceived}}{{ 'GATEWAY.STATS-TXPACKETSEMITTED' | translate }} - {{element.txPacketsEmitted}}{{ 'GATEWAY.STATS-TIMESTAMP' | translate }}{{element.timestamp | date}}
- - +
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
+ {{ '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"> - +
@@ -20,6 +20,12 @@
+ +
+ +
+
- \ 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; }