diff --git a/.gitignore b/.gitignore index 3a4af54b75..e9a4e11b85 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,10 @@ pom.xml.versionsBackup **/build **/target **/Californium.properties +**/Californium3.properties **/.env .instance_id rebuild-docker.sh */.run/** .run/** -.run \ No newline at end of file +.run diff --git a/README.md b/README.md index 5520645541..581e71977d 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,20 @@ ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/d ## IoT use cases -[**Smart metering**](https://thingsboard.io/smart-metering/) -[![Smart metering](https://user-images.githubusercontent.com/8308069/31455788-6888a948-aec1-11e7-9819-410e0ba785e0.gif "Smart metering")](https://thingsboard.io/smart-metering/) - -[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) -[![IoT Rule Engine](https://thingsboard.io/images/demo/send-email-rule-chain.gif "IoT Rule Engine")](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) - [**Smart energy**](https://thingsboard.io/smart-energy/) -[![Smart energy](https://cloud.githubusercontent.com/assets/8308069/24495682/aebd45d0-153e-11e7-8de4-7360ed5b41ae.gif "Smart energy")](https://thingsboard.io/smart-energy/) +[![Smart energy](https://user-images.githubusercontent.com/8308069/152984256-eb48564a-645c-468d-912b-f554b63104a5.gif "Smart energy")](https://thingsboard.io/smart-energy/) + +[**Fleet tracking**](https://thingsboard.io/fleet-tracking/) +[![Fleet tracking](https://user-images.githubusercontent.com/8308069/152984528-0054ed55-8b8b-4cda-ba45-02fe95a81222.gif "Fleet tracking")](https://thingsboard.io/fleet-tracking/) [**Smart farming**](https://thingsboard.io/smart-farming/) -[![Smart farming](https://cloud.githubusercontent.com/assets/8308069/24496824/10dc1144-1542-11e7-8aa1-5d3a281d5a1a.gif "Smart farming")](https://thingsboard.io/smart-farming/) +[![Smart farming](https://user-images.githubusercontent.com/8308069/152984443-a98b7d3d-ff7a-4037-9011-e71e1e6f755f.gif "Smart farming")](https://thingsboard.io/smart-farming/) -[**Fleet tracking**](https://thingsboard.io/fleet-tracking/) -[![Fleet tracking](https://cloud.githubusercontent.com/assets/8308069/24497169/3a1a61e0-1543-11e7-8d55-3c8a13f35634.gif "Fleet tracking")](https://thingsboard.io/fleet-tracking/) +[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) +[![IoT Rule Engine](https://thingsboard.io/images/demo/send-email-rule-chain.gif "IoT Rule Engine")](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) + +[**Smart metering**](https://thingsboard.io/smart-metering/) +[![Smart metering](https://user-images.githubusercontent.com/8308069/31455788-6888a948-aec1-11e7-9819-410e0ba785e0.gif "Smart metering")](https://thingsboard.io/smart-metering/) ## Getting Started diff --git a/application/pom.xml b/application/pom.xml index 405f3b4d7d..1b89752925 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -1,6 +1,6 @@ 2.8.0 4.1.1 2.57 @@ -110,7 +112,7 @@ 1.4.3 1.9.4 3.2.2 - 1.5.2 + 1.8.3 1.0.3TB 3.4.0 8.17.0 @@ -125,7 +127,7 @@ 2.7.2 2.6.1 1.5.2 - 5.6.3 + 5.7.2 2.6.0 1.3.0 1.2.7 @@ -403,39 +405,9 @@ false - - ../common/transport/lwm2m/src/main/resources - - **/*.xml - **/*.jks - - false - - - - - - copy-lwm2m-resources - ${pkg.process-resources.phase} - - copy-resources - - - ../transport/lwm2m/src/main/data - - - ../common/transport/lwm2m/src/main/resources - - **/*.xml - **/*.jks - - false - - - copy-docker-config ${pkg.process-resources.phase} @@ -515,6 +487,7 @@ repackage + build-info @@ -829,10 +802,6 @@ **/*.proto.js docker/haproxy/** docker/tb-node/** - src/main/resources/models/*.xml - src/main/resources/credentials/*.jks - src/main/resources/credentials/shell/*.jks - src/main/resources/credentials/shell/*.jks.old ui/** src/.browserslistrc **/yarn.lock @@ -840,6 +809,7 @@ **/*.patch **/apache/cassandra/io/** .run/** + **/NetworkReceive.java JAVADOC_STYLE diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000000..6e7c31967e --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,30 @@ +## Pull Request description + +Put your PR description here instead of this sentence. + +## General checklist + +- [ ] You have reviewed the guidelines [document](https://docs.google.com/document/d/1wqcOafLx5hth8SAg4dqV_LV3un3m5WYR8RdTJ4MbbUM/edit?usp=sharing). +- [ ] PR name contains fix version. For example, "[3.3.4] Hotfix of some UI component" or "[3.4] New Super Feature". +- [ ] Description references specific [issue](https://github.com/thingsboard/thingsboard/issues). +- [ ] Description contains human-readable scope of changes. +- [ ] Description contains brief notes about what needs to be added to the documentation. +- [ ] No merge conflicts, commented blocks of code, code formatting issues. +- [ ] Changes are backward compatible or upgrade script is provided. +- [ ] Similar PR is opened for PE version to simplify merge. Required for internal contributors only. + +## Front-End feature checklist + +- [ ] Screenshots with affected component(s) are added. The best option is to provide 2 screens: before and after changes; +- [ ] If you change the widget or other API, ensure it is backward-compatible or upgrade script is present. +- [ ] Ensure new API is documented [here](https://github.com/thingsboard/thingsboard-ui-help) + +## Back-End feature checklist + +- [ ] Added corresponding unit and/or integration test(s). Provide written explanation in the PR description if you have failed to add tests. +- [ ] If new dependency was added: the dependency tree is checked for conflicts. +- [ ] If new service was added: the service is marked with corresponding @TbCoreComponent, @TbRuleEngineComponent, @TbTransportComponent, etc. +- [ ] If new REST API was added: the RestClient.java was updated, issue for [Python REST client](https://github.com/thingsboard/thingsboard-python-rest-client) is created. + + + diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 36ec99fab6..40c07c965f 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -1,6 +1,6 @@ - + [parentDashboard]="parentDashboard" + [parentAliasController]="parentAliasController"> +
+ {{ 'dashboard.non-existent-dashboard-state-error' | translate:{stateId} }} +
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.scss new file mode 100644 index 0000000000..01322e7215 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.scss @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + position: relative; + display: block; + width: 100%; + height: 100%; +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.ts index fb22d9f3c2..50863b0595 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,14 +14,14 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, HostBinding, Input, OnDestroy, OnInit } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { Dashboard } from '@shared/models/dashboard.models'; -import { StateObject } from '@core/api/widget-api.models'; +import { Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models'; +import { IAliasController, StateObject } from '@core/api/widget-api.models'; import { updateEntityParams, WidgetContext } from '@home/models/widget-component.models'; -import { deepClone, objToBase64 } from '@core/utils'; +import { deepClone, isDefinedAndNotNull, isNotEmptyStr, objToBase64 } from '@core/utils'; import { IDashboardComponent } from '@home/models/dashboard-component.models'; import { EntityId } from '@shared/models/id/entity-id'; import { Subscription } from 'rxjs'; @@ -29,7 +29,7 @@ import { Subscription } from 'rxjs'; @Component({ selector: 'tb-dashboard-state', templateUrl: './dashboard-state.component.html', - styleUrls: [] + styleUrls: ['./dashboard-state.component.scss'] }) export class DashboardStateComponent extends PageComponent implements OnInit, OnDestroy { @@ -42,6 +42,15 @@ export class DashboardStateComponent extends PageComponent implements OnInit, On @Input() syncParentStateParams = false; + @Input() + defaultAutofillLayout = true; + + @Input() + defaultMargin; + + @Input() + defaultBackgroundColor; + @Input() entityParamName: string; @@ -54,6 +63,10 @@ export class DashboardStateComponent extends PageComponent implements OnInit, On parentDashboard: IDashboardComponent; + parentAliasController: IAliasController; + + stateExists = true; + private stateSubscription: Subscription; constructor(protected store: Store, @@ -63,14 +76,32 @@ export class DashboardStateComponent extends PageComponent implements OnInit, On ngOnInit(): void { this.dashboard = deepClone(this.ctx.stateController.dashboardCtrl.dashboardCtx.getDashboard()); - this.updateCurrentState(); - this.parentDashboard = this.ctx.parentDashboard ? - this.ctx.parentDashboard : this.ctx.dashboard; - if (this.syncParentStateParams) { - this.stateSubscription = this.ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => { - this.updateCurrentState(); - this.cd.markForCheck(); - }); + const state = this.dashboard.configuration.states[this.stateId]; + if (state) { + for (const layoutId of Object.keys(state.layouts)) { + if (this.defaultAutofillLayout) { + state.layouts[layoutId as DashboardLayoutId].gridSettings.autoFillHeight = true; + state.layouts[layoutId as DashboardLayoutId].gridSettings.mobileAutoFillHeight = true; + } + if (isDefinedAndNotNull(this.defaultMargin)) { + state.layouts[layoutId as DashboardLayoutId].gridSettings.margin = this.defaultMargin; + } + if (isNotEmptyStr(this.defaultBackgroundColor)) { + state.layouts[layoutId as DashboardLayoutId].gridSettings.backgroundColor = this.defaultBackgroundColor; + } + } + this.updateCurrentState(); + this.parentDashboard = this.ctx.parentDashboard ? + this.ctx.parentDashboard : this.ctx.dashboard; + if (this.syncParentStateParams) { + this.parentAliasController = this.parentDashboard.aliasController; + this.stateSubscription = this.ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => { + this.updateCurrentState(); + this.cd.markForCheck(); + }); + } + } else { + this.stateExists = false; } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html index de15c409ec..d7fb4c3f88 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html @@ -1,6 +1,6 @@ + + +
+
+
{{ headerTitle }}
+
{{ headerSubtitle }}
+
+
+ +
+ + +
+
+
+ + + + + + + + +
+ diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss new file mode 100644 index 0000000000..27b3d4f2f8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss @@ -0,0 +1,119 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../scss/constants"; + +:host { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + .settings-card { + margin: 8px; + padding: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + + .details-toolbar { + height: 84px; + min-height: 84px; + border-radius: 4px 4px 0 0; + background: #fff; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + + .mat-toolbar-tools { + padding: 0 8px; + } + + .tb-details-title-header { + min-width: 0; + width: auto; + } + + .tb-details-title { + font-size: 1rem; + font-weight: 500; + + @media #{$mat-gt-sm} { + font-size: 1.2rem; + } + } + + .tb-details-subtitle { + font-size: 0.9rem; + opacity: .8; + } + + .tb-ellipsis { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @media #{$mat-md} { + width: 80%; + } + + @media #{$mat-gt-md} { + width: 60%; + } + + .tb-header-button { + .tb-btn-header { + position: relative !important; + display: inline-block !important; + animation: tbMoveFromTopFade .3s ease both; + + &.tb-hide { + animation: tbMoveToTopFade .3s ease both; + } + } + } + } +} + +:host ::ng-deep { + .tb-help { + .mat-icon-button.mat-primary { + color: rgba(0, 0, 0, 0.52); + } + } + + .mat-card-content { + position: relative; + overflow: hidden; + + > .mat-tab-group { + > .mat-tab-body-wrapper { + position: absolute; + top: 49px; + left: 0; + right: 0; + bottom: 0; + } + > .mat-tab-header { + .mat-tab-label { + min-width: 40px; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts new file mode 100644 index 0000000000..d0b141256e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts @@ -0,0 +1,173 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ComponentFactoryResolver, + HostBinding, + Injector, + OnDestroy, + OnInit +} from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormGroup } from '@angular/forms'; +import { AssetId } from '@shared/models/id/asset-id'; +import { TranslateService } from '@ngx-translate/core'; +import { deepClone } from '@core/utils'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { EntityDetailsPanelComponent } from '@home/components/entity/entity-details-panel.component'; +import { DialogService } from '@core/services/dialog.service'; +import { IEntityDetailsPageComponent } from '@home/models/entity/entity-details-page-component.models'; + +@Component({ + selector: 'tb-entity-details-page', + templateUrl: './entity-details-page.component.html', + styleUrls: ['./entity-details-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EntityDetailsPageComponent extends EntityDetailsPanelComponent implements IEntityDetailsPageComponent, OnInit, OnDestroy { + + headerTitle: string; + headerSubtitle: string; + + isReadOnly = false; + + set entitiesTableConfig(entitiesTableConfig: EntityTableConfig>) { + if (this.entitiesTableConfigValue !== entitiesTableConfig) { + this.entitiesTableConfigValue = entitiesTableConfig; + if (this.entitiesTableConfigValue) { + this.entitiesTableConfigValue.setEntityDetailsPage(this); + this.isEdit = false; + this.entity = null; + } + } + } + + get entitiesTableConfig(): EntityTableConfig> { + return this.entitiesTableConfigValue; + } + + @HostBinding('class') 'tb-absolute-fill'; + + constructor(private route: ActivatedRoute, + private router: Router, + protected injector: Injector, + protected cd: ChangeDetectorRef, + protected componentFactoryResolver: ComponentFactoryResolver, + private broadcast: BroadcastService, + private translate: TranslateService, + private dialogService: DialogService, + protected store: Store) { + super(store, injector, cd, componentFactoryResolver); + this.entitiesTableConfig = this.route.snapshot.data.entitiesTableConfig; + } + + ngOnInit() { + this.headerSubtitle = ''; + this.headerSubtitle = this.translate.instant(this.entitiesTableConfig.entityTranslations.details); + super.init(); + this.entityComponent.isDetailsPage = true; + this.subscriptions.push(this.entityAction.subscribe((action) => { + if (action.action === 'delete') { + this.deleteEntity(action.event, action.entity); + } + })); + this.subscriptions.push(this.route.paramMap.subscribe( paramMap => { + if (this.entitiesTableConfig) { + const entityType = this.entitiesTableConfig.entityType; + const id = paramMap.get('entityId'); + this.currentEntityId = { id, entityType }; + this.reload(); + this.selectedTab = 0; + } + })); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + reload(): void { + this.reloadEntity().subscribe(() => { + this.onUpdateEntity(); + }); + } + + onToggleDetailsEditMode() { + if (this.isEdit) { + this.entityComponent.entity = this.entity; + if (this.entityTabsComponent) { + this.entityTabsComponent.entity = this.entity; + } + this.isEdit = !this.isEdit; + } else { + this.isEdit = !this.isEdit; + this.editingEntity = deepClone(this.entity); + this.entityComponent.entity = this.editingEntity; + if (this.entityTabsComponent) { + this.entityTabsComponent.entity = this.editingEntity; + } + if (this.entitiesTableConfig.hideDetailsTabsOnEdit) { + this.selectedTab = 0; + } + } + } + + onApplyDetails() { + this.saveEntity(false).subscribe((entity) => { + if (entity) { + this.onUpdateEntity(); + } + }); + } + + confirmForm(): FormGroup { + return this.detailsForm; + } + + private onUpdateEntity() { + this.broadcast.broadcast('updateBreadcrumb'); + this.isReadOnly = this.entitiesTableConfig.detailsReadonly(this.entity); + this.headerTitle = this.entitiesTableConfig.entityTitle(this.entity); + } + + private deleteEntity($event: Event, entity: BaseData) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.entitiesTableConfig.deleteEntityTitle(entity), + this.entitiesTableConfig.deleteEntityContent(entity), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.entitiesTableConfig.deleteEntity(entity.id).subscribe( + () => { + this.router.navigate(['../'], {relativeTo: this.route}); + } + ); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html index c5f1ec3671..cba0b85ce9 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html @@ -1,6 +1,6 @@ -
-
+
+ {{('device-profile.condition' | translate) + ': '}} +
+ + {{ 'device-profile.propagate-alarm-to-owner' | translate }} + + + {{ 'device-profile.propagate-alarm-to-tenant' | translate }} +
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.scss b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.scss index 3546e48ca4..8596462c3e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts index 20ea89e53c..81ec55261e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -97,7 +97,9 @@ export class DeviceProfileAlarmComponent implements ControlValueAccessor, OnInit createRules: [null], clearRule: [null], propagate: [null], - propagateRelationTypes: [null] + propagateRelationTypes: [null], + propagateToOwner: [null], + propagateToTenant: [null] }, { validators: deviceProfileAlarmValidator }); this.alarmFormGroup.valueChanges.subscribe(() => { this.updateModel(); diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html index df0d5af5a3..dde3df31c1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html @@ -1,6 +1,6 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss index dfb94a7383..453c4ab897 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ :host { width: 100%; height: 100%; + display: block; .tb-entity-table { .tb-entity-table-content { width: 100%; @@ -41,6 +42,9 @@ .table-container { overflow: auto; + .link { + border-bottom: none; + } } .tb-entity-table-info{ diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts index b10ed82249..6b2649ec9a 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnInit, + ViewChild +} from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { PageLink } from '@shared/models/page/page-link'; import { MatPaginator } from '@angular/material/paginator'; @@ -38,6 +47,8 @@ import { import { EntityId } from '@shared/models/id/entity-id'; import { RelationsDatasource } from '../../models/datasource/relation-datasource'; import { RelationDialogComponent, RelationDialogData } from '@home/components/relation/relation-dialog.component'; +import { hidePageSizePixelValue } from '@shared/models/constants'; +import { ResizeObserver } from '@juggle/resize-observer'; @Component({ selector: 'tb-relation-table', @@ -56,6 +67,7 @@ export class RelationTableComponent extends PageComponent implements AfterViewIn displayedColumns: string[]; direction: EntitySearchDirection; pageLink: PageLink; + hidePageSize = false; textSearchMode = false; dataSource: RelationsDatasource; @@ -65,6 +77,8 @@ export class RelationTableComponent extends PageComponent implements AfterViewIn viewsInited = false; + private widgetResize$: ResizeObserver; + @Input() set active(active: boolean) { if (this.activeValue !== active) { @@ -100,7 +114,9 @@ export class RelationTableComponent extends PageComponent implements AfterViewIn private entityRelationService: EntityRelationService, public translate: TranslateService, public dialog: MatDialog, - private dialogService: DialogService) { + private dialogService: DialogService, + private cd: ChangeDetectorRef, + private elementRef: ElementRef) { super(store); this.dirtyValue = !this.activeValue; const sortOrder: SortOrder = { property: 'type', direction: Direction.ASC }; @@ -111,6 +127,20 @@ export class RelationTableComponent extends PageComponent implements AfterViewIn } ngOnInit() { + this.widgetResize$ = new ResizeObserver(() => { + const showHidePageSize = this.elementRef.nativeElement.offsetWidth < hidePageSizePixelValue; + if (showHidePageSize !== this.hidePageSize) { + this.hidePageSize = showHidePageSize; + this.cd.markForCheck(); + } + }); + this.widgetResize$.observe(this.elementRef.nativeElement); + } + + ngOnDestroy() { + if (this.widgetResize$) { + this.widgetResize$.disconnect(); + } } updateColumns() { diff --git a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html index c3ee17288e..fd99e1bc54 100644 --- a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html @@ -1,6 +1,6 @@ - +
diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.scss b/ui-ngx/src/app/modules/home/components/widget/legend.component.scss index f4e5284203..9aefd0c88d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/legend.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ &.tb-legend-row { width: auto; margin-left: auto; + margin-right: auto; } .tb-legend-header, diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.ts b/ui-ngx/src/app/modules/home/components/widget/legend.component.ts index 004fd5087b..0074280732 100644 --- a/ui-ngx/src/app/modules/home/components/widget/legend.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -60,11 +60,13 @@ export class LegendComponent implements OnInit { } legendKeys(): LegendKey[] { - let keys = this.legendData.keys; - if (this.legendConfig.sortDataKeys) { - keys = this.legendData.keys.sort((key1, key2) => key1.dataKey.label.localeCompare(key2.dataKey.label)); - } - return keys.filter(legendKey => this.legendData.keys[legendKey.dataIndex].dataKey.inLegend); + try { + let keys = this.legendData.keys; + if (this.legendConfig.sortDataKeys) { + keys = this.legendData.keys.sort((key1, key2) => key1.dataKey.label.localeCompare(key2.dataKey.label)); + } + return keys.filter(legendKey => this.legendData.keys[legendKey.dataIndex].dataKey.inLegend); + } catch (e) {} } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.html index a252d37586..9741440574 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.html @@ -1,6 +1,6 @@ -
this.editing = true); + this.leafletCircle.on('pm:dragend', () => this.editing = false); + // Change radius (call in edit mode) + this.leafletCircle.on('pm:markerdragstart', () => this.editing = true); + this.leafletCircle.on('pm:markerdragend', () => this.editing = false); + this.leafletCircle.on('pm:edit', (e) => this.onDragendListener(e, this.data)); + } + + if (this.settings.circleClick) { + this.leafletCircle.on('click', (event: LeafletMouseEvent) => { + for (const action in this.settings.circleClick) { + if (typeof (this.settings.circleClick[action]) === 'function') { + this.settings.circleClick[action](event.originalEvent, this.data.$datasource); + } + } + }); + } + } + + private updateLabel() { + this.leafletCircle.unbindTooltip(); + if (this.settings.showCircleLabel) { + if (!this.map.circleLabelText || this.settings.useCircleLabelFunction) { + const pattern = this.settings.useCircleLabelFunction ? + safeExecute(this.settings.circleLabelFunction, [this.data, this.dataSources, this.data.dsIndex]) : this.settings.circleLabel; + this.map.circleLabelText = parseWithTranslation.prepareProcessPattern(pattern, true); + this.map.replaceInfoTooltipCircle = processDataPattern(this.map.circleLabelText, this.data); + } + const circleLabelText = fillDataPattern(this.map.circleLabelText, this.map.replaceInfoTooltipCircle, this.data); + this.leafletCircle.bindTooltip(`
${circleLabelText}
`, + { className: 'tb-polygon-label', permanent: true, sticky: true, direction: 'center'}) + .openTooltip(this.leafletCircle.getLatLng()); + } + } + + private updateTooltip() { + const pattern = this.settings.useCircleTooltipFunction ? + safeExecute(this.settings.circleTooltipFunction, [this.data, this.dataSources, this.data.dsIndex]) : + this.settings.circleTooltipPattern; + this.tooltip.setContent(parseWithTranslation.parseTemplate(pattern, this.data, true)); + } + + private updateCircleColor() { + const circleFillColor = this.getFillColor(); + const circleStrokeColor = this.getStrokeColor(); + const style: L.PathOptions = { + fillColor: circleFillColor, + color: circleStrokeColor + }; + this.leafletCircle.setStyle(style); + } + + updateCircle(data: FormattedData, dataSources: FormattedData[]) { + if (this.editing) { + return; + } + this.data = data; + this.dataSources = dataSources; + this.circleData = this.map.convertToCircleFormat(JSON.parse(data[this.settings.circleKeyName])); + const newCenterPosition = this.createCenterPosition(); + if (!this.leafletCircle.getLatLng().equals(newCenterPosition) && !this.editing) { + this.leafletCircle.setLatLng(newCenterPosition); + } + if (this.leafletCircle.getRadius() !== this.circleData.radius) { + this.leafletCircle.setRadius(this.circleData.radius); + } + if (this.settings.showCircleLabel) { + this.updateLabel(); + } + if (this.settings.showCircleTooltip) { + this.updateTooltip(); + } + if (this.settings.useCircleStrokeColorFunction || this.settings.useCircleFillColorFunction) { + this.updateCircleColor(); + } + } + + private createCenterPosition(): L.LatLng { + return new L.LatLng(this.circleData.latitude, this.circleData.longitude); + } + + private getFillColor(): string | null { + return functionValueCalculator(this.settings.useCircleFillColorFunction, this.settings.circleFillColorFunction, + [this.data, this.dataSources, this.data.dsIndex], this.settings.circleFillColor); + } + + private getStrokeColor(): string | null { + return functionValueCalculator(this.settings.useCircleStrokeColorFunction, this.settings.circleStrokeColorFunction, + [this.data, this.dataSources, this.data.dsIndex], this.settings.circleStrokeColor); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts index 9f1810f82f..94f7e70ffb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { FormattedData, MapProviders, ReplaceInfo } from '@home/components/widget/lib/maps/map-models'; +import { MapProviders } from '@home/components/widget/lib/maps/map-models'; import { createLabelFromDatasource, hashCode, @@ -27,7 +27,7 @@ import { } from '@core/utils'; import { Observable, Observer, of } from 'rxjs'; import { map } from 'rxjs/operators'; -import { Datasource, DatasourceData } from '@shared/models/widget.models'; +import { FormattedData } from '@shared/models/widget.models'; import _ from 'lodash'; import { mapProviderSchema, providerSets } from '@home/components/widget/lib/maps/schemes'; import { addCondition, mergeSchemes } from '@core/schema-utils'; @@ -123,6 +123,7 @@ export function aspectCache(imageUrl: string): Observable { return aspect; })); } + return of(0); } export type TranslateFunc = (key: string, defaultTranslation?: string) => string; @@ -138,7 +139,7 @@ function createButtonElement(actionName: string, actionText: string) { return ``; } -function parseTemplate(template: string, data: { $datasource?: Datasource, [key: string]: any }, +function parseTemplate(template: string, data: FormattedData, translateFn?: TranslateFunc) { let res = ''; try { @@ -207,67 +208,6 @@ function parseTemplate(template: string, data: { $datasource?: Datasource, [key: return res; } -export function processPattern(template: string, data: { $datasource?: Datasource, [key: string]: any }): Array { - const replaceInfo = []; - try { - const reg = /\${([^}]*)}/g; - let match = reg.exec(template); - while (match !== null) { - const variableInfo: ReplaceInfo = { - dataKeyName: '', - valDec: 2, - variable: '' - }; - const variable = match[0]; - let label = match[1]; - let valDec = 2; - const splitValues = label.split(':'); - if (splitValues.length > 1) { - label = splitValues[0]; - valDec = parseFloat(splitValues[1]); - } - - variableInfo.variable = variable; - variableInfo.valDec = valDec; - - if (label.startsWith('#')) { - const keyIndexStr = label.substring(1); - const n = Math.floor(Number(keyIndexStr)); - if (String(n) === keyIndexStr && n >= 0) { - variableInfo.dataKeyName = data.$datasource.dataKeys[n].label; - } - } else { - variableInfo.dataKeyName = label; - } - replaceInfo.push(variableInfo); - - match = reg.exec(template); - } - } catch (ex) { - console.log(ex, template); - } - return replaceInfo; -} - -export function fillPattern(markerLabelText: string, replaceInfoLabelMarker: Array, data: FormattedData) { - let text = createLabelFromDatasource(data.$datasource, markerLabelText); - if (replaceInfoLabelMarker) { - for (const variableInfo of replaceInfoLabelMarker) { - let txtVal = ''; - if (variableInfo.dataKeyName && isDefinedAndNotNull(data[variableInfo.dataKeyName])) { - const varData = data[variableInfo.dataKeyName]; - if (isNumber(varData)) { - txtVal = padValue(varData, variableInfo.valDec); - } else { - txtVal = varData; - } - } - text = text.replace(variableInfo.variable, txtVal); - } - } - return text; -} - function prepareProcessPattern(template: string, translateFn?: TranslateFunc): string { if (translateFn) { template = translateFn(template); @@ -307,7 +247,7 @@ export const parseWithTranslation = { throw Error('Translate not assigned'); } }, - parseTemplate(template: string, data: object, forceTranslate = false): string { + parseTemplate(template: string, data: FormattedData, forceTranslate = false): string { return parseTemplate(forceTranslate ? this.translate(template) : template, data, this.translate.bind(this)); }, prepareProcessPattern(template: string, forceTranslate = false): string { @@ -318,106 +258,6 @@ export const parseWithTranslation = { } }; -export function parseData(input: DatasourceData[], dataIndex?: number): FormattedData[] { - return _(input).groupBy(el => el?.datasource.entityName + el?.datasource.entityType) - .values().value().map((entityArray, i) => { - const obj: FormattedData = { - entityName: entityArray[0]?.datasource?.entityName, - entityId: entityArray[0].datasource.entityId, - entityType: entityArray[0].datasource.entityType, - $datasource: entityArray[0].datasource, - dsIndex: i, - deviceType: null - }; - entityArray.filter(el => el.data.length).forEach(el => { - const index = isDefined(dataIndex) ? dataIndex : el.data.length - 1; - if (!obj.hasOwnProperty(el.dataKey.label) || el.data[index][1] !== '') { - obj[el.dataKey.label] = el.data[index][1]; - obj[el.dataKey.label + '|ts'] = el.data[index][0]; - if (el.dataKey.label.toLowerCase() === 'type') { - obj.deviceType = el.data[index][1]; - } - } - }); - return obj; - }); -} - -export function flatData(input: FormattedData[]): FormattedData { - let result: FormattedData = {} as FormattedData; - if (input.length) { - for (const toMerge of input) { - result = {...result, ...toMerge}; - } - result.entityName = input[0].entityName; - result.entityId = input[0].entityId; - result.entityType = input[0].entityType; - result.$datasource = input[0].$datasource; - result.dsIndex = input[0].dsIndex; - result.deviceType = input[0].deviceType; - } - return result; -} - -export function parseArray(input: DatasourceData[]): FormattedData[][] { - return _(input).groupBy(el => el.datasource.entityName) - .values().value().map((entityArray, dsIndex) => { - const timeDataMap: {[time: number]: FormattedData} = {}; - entityArray.filter(e => e.data.length).forEach(entity => { - entity.data.forEach(tsData => { - const time = tsData[0]; - const value = tsData[1]; - let data = timeDataMap[time]; - if (!data) { - data = { - entityName: entity.datasource.entityName, - entityId: entity.datasource.entityId, - entityType: entity.datasource.entityType, - $datasource: entity.datasource, - dsIndex, - time, - deviceType: null - }; - timeDataMap[time] = data; - } - data[entity.dataKey.label] = value; - data[entity.dataKey.label + '|ts'] = time; - if (entity.dataKey.label.toLowerCase() === 'type') { - data.deviceType = value; - } - }); - }); - return _.values(timeDataMap); - }); -} - -export function parseFunction(source: any, params: string[] = ['def']): (...args: any[]) => any { - let res = null; - if (source?.length) { - try { - res = new Function(...params, source); - } - catch (err) { - res = null; - } - } - return res; -} - -export function safeExecute(func: (...args: any[]) => any, params = []) { - let res = null; - if (func && typeof (func) === 'function') { - try { - res = func(...params); - } - catch (err) { - console.log('error in external function:', err); - res = null; - } - } - return res; -} - export function functionValueCalculator(useFunction: boolean, func: (...args: any[]) => any, params = [], defaultValue: any) { let res; if (useFunction && isDefined(func) && isFunction(func)) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html index 93fa72c84b..de34d1e01b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html @@ -1,6 +1,6 @@ + + +

{{ 'widgets.persistent-table.add-title' | translate }}

+ + +
+ + +
+
+
+
+ + {{ rpcMessageTypeText }} + +
+
+ + widgets.persistent-table.method + + + {{'widgets.persistent-table.method-error' | translate}} + + + {{'widgets.persistent-table.white-space-error' | translate}} + + + + widgets.persistent-table.retries + + +
+
+ + +
+ + + + widgets.persistent-table.additional-info + + + + + + + +
+
+
+
+ + +
+ +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.scss new file mode 100644 index 0000000000..4958d4efa8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .add-dialog { + + .params-json-editor, + .additional-json-editor { + .tb-json-object-panel { + margin: 0 0 16px; + } + + .mat-expansion-panel-body { + padding-bottom: 0 !important; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.ts new file mode 100644 index 0000000000..6e81d98ce7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.ts @@ -0,0 +1,75 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { RequestData } from '@shared/models/rpc.models'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-persistent-add-dialog', + templateUrl: './persistent-add-dialog.component.html', + styleUrls: ['./persistent-add-dialog.component.scss'] +}) + +export class PersistentAddDialogComponent extends DialogComponent implements OnInit { + + public persistentFormGroup: FormGroup; + public rpcMessageTypeText: string; + + private requestData: RequestData = null; + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private translate: TranslateService) { + super(store, router, dialogRef); + + this.persistentFormGroup = this.fb.group( + { + method: ['', [Validators.required, Validators.pattern(/^\S+$/)]], + oneWayElseTwoWay: [false], + retries: [null, [Validators.pattern(/^-?[0-9]+$/), Validators.min(0)]], + params: [null], + additionalInfo: [null] + } + ); + } + + save() { + this.requestData = this.persistentFormGroup.value; + this.close(); + } + + ngOnInit(): void { + this.rpcMessageTypeText = this.translate.instant('widgets.persistent-table.message-types.false'); + this.persistentFormGroup.get('oneWayElseTwoWay').valueChanges.subscribe( + () => { + this.rpcMessageTypeText = this.translate.instant(`widgets.persistent-table.message-types.${this.persistentFormGroup.get('oneWayElseTwoWay').value}`); + } + ); + } + + close(): void { + this.dialogRef.close(this.requestData); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.html new file mode 100644 index 0000000000..b9e57b4bbd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.html @@ -0,0 +1,118 @@ + +
+ +

{{ persistentFormGroup.get('rpcId').value }}

+ + +
+ + +
+
+
+
+ + widgets.persistent-table.created-time + + + + widgets.persistent-table.expiration-time + + +
+
+ + widgets.persistent-table.message-type + + + + widgets.persistent-table.status + + + + widgets.persistent-table.method + + + + widgets.persistent-table.retries + + +
+ + + + + + {{ 'widgets.persistent-table.response' | translate }} + + + + + + + + {{ 'widgets.persistent-table.params' | translate }} + + + + + + + + + + {{ 'widgets.persistent-table.additional-info' | translate }} + + + + + + + + +
+
+
+ + +
+ +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.scss new file mode 100644 index 0000000000..9025d6199e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .rpc-dialog { + .mat-expansion-panel-body { + padding-bottom: 0 !important; + } + + .tb-json-object-panel { + margin: 0 0 16px 0; + } + } + + .tb-audit-log-response-data { + width: 100%; + min-width: 400px; + height: 100%; + min-height: 100px; + border: 1px solid #c0c0c0; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.ts new file mode 100644 index 0000000000..a072ea312b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.ts @@ -0,0 +1,126 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { DatePipe } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { DeviceService } from '@core/http/device.service'; +import { PersistentRpc, RpcStatus, rpcStatusColors, rpcStatusTranslation } from '@shared/models/rpc.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { DialogService } from '@core/services/dialog.service'; + +export interface PersistentDetailsDialogData { + persistentRequest: PersistentRpc; + allowDelete: boolean; +} + +@Component({ + selector: 'tb-persistent-details-dialog', + templateUrl: './persistent-details-dialog.component.html', + styleUrls: ['./persistent-details-dialog.component.scss'] +}) + +export class PersistentDetailsDialogComponent extends DialogComponent implements OnInit { + + @ViewChild('responseDataEditor', {static: true}) + responseDataEditorElmRef: ElementRef; + + public persistentFormGroup: FormGroup; + public rpcStatusColorsMap = rpcStatusColors; + public rpcStatus = RpcStatus; + public allowDelete: boolean; + + private persistentUpdated = false; + private responseData: string; + + constructor(protected store: Store, + protected router: Router, + private datePipe: DatePipe, + private translate: TranslateService, + @Inject(MAT_DIALOG_DATA) public data: PersistentDetailsDialogData, + public dialogRef: MatDialogRef, + private dialogService: DialogService, + private deviceService: DeviceService, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.allowDelete = data.allowDelete; + + this.persistentFormGroup = this.fb.group( + { + rpcId: [''], + createdTime: [''], + expirationTime: [''], + messageType: [''], + status: [''], + method: [''], + params: [''], + retries: [''], + response: [''], + additionalInfo: [''] + } + ); + this.loadPersistentFields(data.persistentRequest); + this.responseData = JSON.stringify(data.persistentRequest.response, null, 2); + } + + loadPersistentFields(request: PersistentRpc) { + this.persistentFormGroup.patchValue({ + rpcId: this.translate.instant('widgets.persistent-table.details-title') + request.id.id, + createdTime: this.datePipe.transform(request.createdTime, 'yyyy-MM-dd HH:mm:ss'), + expirationTime: this.datePipe.transform(request.expirationTime, 'yyyy-MM-dd HH:mm:ss'), + messageType: this.translate.instant('widgets.persistent-table.message-types.' + request.request.oneway), + status: this.translate.instant(rpcStatusTranslation.get(request.status)), + method: request.request.body.method, + retries: request.request.retries || null, + response: request.response || null, + params: JSON.parse(request.request.body.params) || null, + additionalInfo: request.additionalInfo || null + }, {emitEvent: false}); + } + + ngOnInit(): void { + } + + close(): void { + this.dialogRef.close(this.persistentUpdated); + } + + deleteRpcRequest() { + const persistentRpc = this.data.persistentRequest; + if (persistentRpc && persistentRpc.id && persistentRpc.id.id !== NULL_UUID) { + this.dialogService.confirm( + this.translate.instant('widgets.persistent-table.delete-request-title'), + this.translate.instant('widgets.persistent-table.delete-request-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe((res) => { + if (res) { + this.deviceService.deletePersistedRpc(persistentRpc.id.id).subscribe(() => { + this.persistentUpdated = true; + this.close(); + }); + } + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.html new file mode 100644 index 0000000000..b3bb295ed2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.html @@ -0,0 +1,44 @@ + +
+ + widgets.persistent-table.rpc-status-list + + + {{ 'widgets.persistent-table.rpc-search-status-all' | translate }} + + + {{ rpcSearchStatusTranslationMap.get(searchStatus) | translate }} + + + +
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.scss new file mode 100644 index 0000000000..45ba061843 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + width: 100%; + height: 100%; + min-width: 300px; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + .mat-content { + overflow: hidden; + background-color: #fff; + } + + .mat-padding { + padding: 16px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.ts new file mode 100644 index 0000000000..e3d46dfca3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.ts @@ -0,0 +1,67 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, InjectionToken } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { OverlayRef } from '@angular/cdk/overlay'; +import { RpcStatus, rpcStatusTranslation } from '@shared/models/rpc.models'; +import { TranslateService } from '@ngx-translate/core'; + +export const PERSISTENT_FILTER_PANEL_DATA = new InjectionToken('AlarmFilterPanelData'); + +export interface PersistentFilterPanelData { + rpcStatus: RpcStatus; +} + +@Component({ + selector: 'tb-persistent-filter-panel', + templateUrl: './persistent-filter-panel.component.html', + styleUrls: ['./persistent-filter-panel.component.scss'] +}) +export class PersistentFilterPanelComponent { + + public persistentFilterFormGroup: FormGroup; + public result: PersistentFilterPanelData; + public rpcSearchStatusTranslationMap = rpcStatusTranslation; + public rpcSearchPlaceholder: string; + + public persistentSearchStatuses = Object.keys(RpcStatus); + + constructor(@Inject(PERSISTENT_FILTER_PANEL_DATA) + public data: PersistentFilterPanelData, + public overlayRef: OverlayRef, + private fb: FormBuilder, + private translate: TranslateService) { + this.persistentFilterFormGroup = this.fb.group( + { + rpcStatus: this.data.rpcStatus + } + ); + this.rpcSearchPlaceholder = this.translate.instant('widgets.persistent-table.any-status'); + } + + update() { + this.result = { + rpcStatus: this.persistentFilterFormGroup.get('rpcStatus').value + }; + this.overlayRef.dispose(); + } + + cancel() { + this.overlayRef.dispose(); + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.html new file mode 100644 index 0000000000..7614d7e0e6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.html @@ -0,0 +1,126 @@ + +
+
+
+
+ + + {{ 'widgets.persistent-table.rpc-id' | translate }} + + + {{ column.id.id }} + + + + + {{ 'widgets.persistent-table.created-time' | translate }} + + + {{ column.createdTime | date:'yyyy-MM-dd HH:mm:ss' }} + + + + + {{ 'widgets.persistent-table.expiration-time' | translate }} + + + {{ column.expirationTime | date:'yyyy-MM-dd HH:mm:ss' }} + + + + + {{ 'widgets.persistent-table.status' | translate }} + + + {{ rpcStatusTranslation.get(column.status) | translate }} + + + + + {{ 'widgets.persistent-table.message-type' | translate }} + + + {{ 'widgets.persistent-table.message-types.' + column.request.oneway | translate }} + + + + + {{ 'widgets.persistent-table.method' | translate }} + + + {{ column.request.body.method }} + + + + + + +
+ + + +
+
+ + + + + + +
+
+
+ + +
+ {{ noDataDisplayMessageText }} + {{ 'common.loading' | translate }} +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.scss new file mode 100644 index 0000000000..842d53d143 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + width: 100%; + height: 100%; + display: block; + .tb-table-widget { + .table-container { + position: relative; + } + + .mat-table { + .mat-row { + &.invisible { + visibility: hidden; + } + } + } + + span.no-data-found { + position: absolute; + top: 60px; + bottom: 0; + left: 0; + right: 0; + } + + .column-id { + min-width: 250px; + max-width: 250px; + width: 250px; + } + + .column-time { + min-width: 120px; + max-width: 120px; + width: 120px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts new file mode 100644 index 0000000000..5beef7ea8c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts @@ -0,0 +1,582 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + ElementRef, + Injector, + Input, + OnInit, + StaticProvider, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { WidgetConfig } from '@shared/models/widget.models'; +import { IWidgetSubscription } from '@core/api/widget-api.models'; +import { BehaviorSubject, merge, Observable, of, ReplaySubject, Subject, throwError } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; +import { + constructTableCssString, + noDataMessage, + TableCellButtonActionDescriptor, + TableWidgetSettings +} from '@home/components/widget/lib/table-widget.models'; +import cssjs from '@core/css/css'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { hashCode, isDefined, isNumber } from '@core/utils'; +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { + PersistentRpc, + PersistentRpcData, + RequestData, + RpcStatus, + rpcStatusColors, + rpcStatusTranslation +} from '@shared/models/rpc.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { DialogService } from '@core/services/dialog.service'; +import { DeviceService } from '@core/http/device.service'; +import { MatDialog } from '@angular/material/dialog'; +import { + PersistentDetailsDialogComponent, + PersistentDetailsDialogData +} from '@home/components/widget/lib/rpc/persistent-details-dialog.component'; +import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { + PERSISTENT_FILTER_PANEL_DATA, + PersistentFilterPanelComponent, + PersistentFilterPanelData +} from '@home/components/widget/lib/rpc/persistent-filter-panel.component'; +import { PersistentAddDialogComponent } from '@home/components/widget/lib/rpc/persistent-add-dialog.component'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { hidePageSizePixelValue } from '@shared/models/constants'; +import { HttpErrorResponse } from '@angular/common/http'; + +interface PersistentTableWidgetSettings extends TableWidgetSettings { + defaultSortOrder: string; + defaultPageSize: number; + displayPagination: boolean; + enableStickyAction: boolean; + enableStickyHeader: boolean; + enableFilter: boolean; + displayColumns: string[]; + displayDetails: boolean; + allowDelete: boolean; + allowSendRequest: boolean; +} + +interface PersistentTableWidgetActionDescriptor extends TableCellButtonActionDescriptor { + details?: boolean; + delete?: boolean; +} + +@Component({ + selector: 'tb-persistent-table-widget', + templateUrl: './persistent-table.component.html', + styleUrls: ['./persistent-table.component.scss' , '../table-widget.scss'] +}) + +export class PersistentTableComponent extends PageComponent implements OnInit { + + @Input() + ctx: WidgetContext; + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + private settings: PersistentTableWidgetSettings; + private widgetConfig: WidgetConfig; + private subscription: IWidgetSubscription; + private enableFilterAction = true; + private allowSendRequest = true; + private defaultPageSize = 10; + private defaultSortOrder = '-createdTime'; + private rpcStatusFilter: RpcStatus; + private displayDetails = true; + private allowDelete = true; + private displayTableColumns: string[]; + private widgetResize$: ResizeObserver; + + public persistentDatasource: PersistentDatasource; + public noDataDisplayMessageText: string; + public rpcStatusColor = rpcStatusColors; + public rpcStatusTranslation = rpcStatusTranslation; + public displayPagination = true; + public enableStickyHeader = true; + public enableStickyAction = true; + public pageLink: PageLink; + public pageSizeOptions; + public actionCellButtonAction: PersistentTableWidgetActionDescriptor[] = []; + public displayedColumns: string[]; + public hidePageSize = false; + hasData = false; + + constructor(protected store: Store, + private elementRef: ElementRef, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private utils: UtilsService, + private translate: TranslateService, + private dialogService: DialogService, + private deviceService: DeviceService, + private dialog: MatDialog, + private cd: ChangeDetectorRef) { + super(store); + } + + ngOnInit() { + this.ctx.$scope.persistentTableWidget = this; + this.settings = this.ctx.settings; + this.widgetConfig = this.ctx.widgetConfig; + this.subscription = this.ctx.defaultSubscription; + this.initializeConfig(); + this.hasData = this.ctx.defaultSubscription.hasResolvedData; + this.ctx.updateWidgetParams(); + if (this.displayPagination) { + this.widgetResize$ = new ResizeObserver(() => { + const showHidePageSize = this.elementRef.nativeElement.offsetWidth < hidePageSizePixelValue; + if (showHidePageSize !== this.hidePageSize) { + this.hidePageSize = showHidePageSize; + this.cd.markForCheck(); + } + }); + this.widgetResize$.observe(this.elementRef.nativeElement); + } + } + + ngOnDestroy() { + if (this.widgetResize$) { + this.widgetResize$.disconnect(); + } + } + + ngAfterViewInit(): void { + if (this.displayPagination) { + this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); + } + ((this.displayPagination ? merge(this.sort.sortChange, this.paginator.page) : this.sort.sortChange) as Observable) + .pipe( + tap(() => this.updateData()) + ) + .subscribe(); + this.updateData(); + } + + private initializeConfig() { + + this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true; + this.enableStickyHeader = isDefined(this.settings.enableStickyHeader) ? this.settings.enableStickyHeader : true; + this.displayTableColumns = isDefined(this.settings.displayColumns) ? this.settings.displayColumns : []; + this.enableStickyAction = isDefined(this.settings.enableStickyAction) ? this.settings.enableStickyAction : true; + this.enableFilterAction = isDefined(this.settings.enableFilter) ? this.settings.enableFilter : true; + this.displayDetails = isDefined(this.settings.displayDetails) ? this.settings.displayDetails : true; + this.allowDelete = isDefined(this.settings.allowDelete) ? this.settings.allowDelete : true; + this.allowSendRequest = isDefined(this.settings.allowSendRequest) ? this.settings.allowSendRequest : true; + + this.noDataDisplayMessageText = + noDataMessage(this.widgetConfig.noDataDisplayMessage, 'widgets.persistent-table.no-request-prompt', this.utils, this.translate); + + this.displayedColumns = [...this.displayTableColumns]; + + const pageSize = this.settings.defaultPageSize; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + this.defaultPageSize = pageSize; + } + this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { + this.defaultSortOrder = this.settings.defaultSortOrder; + } + const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); + this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder); + this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; + + + this.ctx.widgetActions = [ + { + name: 'widgets.persistent-table.add', + show: this.allowSendRequest, + icon: 'add', + onAction: $event => this.addPersistentRpcRequest($event) + }, + { + name: 'widgets.persistent-table.refresh', + show: true, + icon: 'refresh', + onAction: () => this.reloadPersistentRequests() + }, + { + name: 'widgets.persistent-table.filter', + show: this.enableFilterAction, + icon: 'filter_list', + onAction: $event => this.editFilter($event) + } + ]; + + if (this.settings.displayDetails) { + this.actionCellButtonAction.push( + { + displayName: this.translate.instant('widgets.persistent-table.details'), + icon: 'more_horiz', + details: true + } as PersistentTableWidgetActionDescriptor + ); + } + if (this.settings.allowDelete) { + this.actionCellButtonAction.push( + { + displayName: this.translate.instant('widgets.persistent-table.delete'), + icon: 'delete', + delete: true + } as PersistentTableWidgetActionDescriptor + ); + } + if (this.actionCellButtonAction.length) { + this.displayedColumns.push('actions'); + } + + this.persistentDatasource = new PersistentDatasource(this.translate, this.subscription, this.ctx); + + const cssString = constructTableCssString(this.widgetConfig); + const cssParser = new cssjs(); + cssParser.testMode = false; + const namespace = 'persistent-table-' + hashCode(cssString); + cssParser.cssPreviewNamespace = namespace; + cssParser.createStyleElement(namespace, cssString); + $(this.elementRef.nativeElement).addClass(namespace); + } + + private updateData() { + if (this.displayPagination) { + this.pageLink.page = this.paginator.pageIndex; + this.pageLink.pageSize = this.paginator.pageSize; + } else { + this.pageLink.page = 0; + } + if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { + this.defaultSortOrder = this.utils.customTranslation(this.settings.defaultSortOrder, this.settings.defaultSortOrder); + } + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + this.persistentDatasource.loadPersistent(this.pageLink, this.rpcStatusFilter); + this.ctx.detectChanges(); + } + + public onDataUpdated() { + this.ctx.detectChanges(); + } + + reloadPersistentRequests() { + if (this.displayPagination) { + this.paginator.pageIndex = 0; + } + this.updateData(); + } + + deleteRpcRequest($event: Event, persistentRpc: PersistentRpc) { + if ($event) { + $event.stopPropagation(); + } + if (persistentRpc && persistentRpc.id && persistentRpc.id.id !== NULL_UUID) { + this.dialogService.confirm( + this.translate.instant('widgets.persistent-table.delete-request-title'), + this.translate.instant('widgets.persistent-table.delete-request-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe((res) => { + if (res) { + this.deviceService.deletePersistedRpc(persistentRpc.id.id).subscribe(() => { + this.reloadPersistentRequests(); + }); + } + }); + } + } + + openRequestDetails($event: Event, persistentRpc: PersistentRpc) { + if ($event) { + $event.stopPropagation(); + } + if (persistentRpc && persistentRpc.id && persistentRpc.id.id !== NULL_UUID) { + this.dialog.open + (PersistentDetailsDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + persistentRequest: persistentRpc, + allowDelete: this.allowDelete + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.reloadPersistentRequests(); + } + } + ); + } + } + + addPersistentRpcRequest($event: Event){ + if ($event) { + $event.stopPropagation(); + } + this.dialog.open + (PersistentAddDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (requestData) => { + if (requestData) { + this.sendRequests(requestData); + } + } + ); + } + + private sendRequests(requestData: RequestData) { + let commandPromise; + if (requestData.oneWayElseTwoWay) { + commandPromise = this.ctx.controlApi.sendOneWayCommand( + requestData.method, + requestData.params, null, + true, null, + requestData.retries, + requestData.additionalInfo + ); + } else { + commandPromise = this.ctx.controlApi.sendTwoWayCommand( + requestData.method, + requestData.params, + null, + true, null, + requestData.retries, + requestData.additionalInfo + ); + } + commandPromise.subscribe( + () => { + this.reloadPersistentRequests(); + } + ); + } + + public onActionButtonClick($event: Event, persistentRpc: PersistentRpc, actionDescriptor: PersistentTableWidgetActionDescriptor) { + if (actionDescriptor.details) { + this.openRequestDetails($event, persistentRpc); + } + if (actionDescriptor.delete) { + this.deleteRpcRequest($event, persistentRpc); + } + } + + private editFilter($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const target = $event.target || $event.srcElement || $event.currentTarget; + const config = new OverlayConfig(); + config.backdropClass = 'cdk-overlay-transparent-backdrop'; + config.hasBackdrop = true; + const connectedPosition: ConnectedPosition = { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + }; + config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) + .withPositions([connectedPosition]); + + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + const providers: StaticProvider[] = [ + { + provide: PERSISTENT_FILTER_PANEL_DATA, + useValue: { + rpcStatus: this.rpcStatusFilter + } as PersistentFilterPanelData + }, + { + provide: OverlayRef, + useValue: overlayRef + } + ]; + const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); + const componentRef = overlayRef.attach(new ComponentPortal(PersistentFilterPanelComponent, + this.viewContainerRef, injector)); + componentRef.onDestroy(() => { + if (componentRef.instance.result) { + const result = componentRef.instance.result; + this.rpcStatusFilter = result.rpcStatus; + this.reloadPersistentRequests(); + } + }); + this.ctx.detectChanges(); + } +} + +class PersistentDatasource implements DataSource { + + private persistentSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + private rpcErrorText: string; + private executingSubjects: Array>; + private executingRpcRequest = false; + + public dataLoading = true; + public pageData$ = this.pageDataSubject.asObservable(); + + constructor(private translate: TranslateService, + private subscription: IWidgetSubscription, + private ctx: WidgetContext) { + } + + connect(collectionViewer: CollectionViewer): Observable> { + return this.persistentSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.persistentSubject.complete(); + this.pageDataSubject.complete(); + } + + reset() { + const pageData = emptyPageData(); + this.persistentSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + } + + loadPersistent(pageLink: PageLink, rpcStatusFilter: RpcStatus) { + this.dataLoading = true; + + const result = new ReplaySubject>(); + this.fetchEntities(pageLink, rpcStatusFilter).pipe( + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.persistentSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + result.next(pageData); + this.dataLoading = false; + } + ); + return result; + } + + fetchEntities(pageLink: PageLink, rpcStatusFilter: RpcStatus): Observable> { + if (!this.subscription.rpcEnabled) { + return throwError(new Error('Rpc disabled!')); + } else if (!this.subscription.targetDeviceId) { + return throwError(new Error('Target device is not set!')); + } + const rpcSubject: Subject = new Subject(); + + this.ctx.deviceService.getPersistedRpcRequests(this.subscription.targetDeviceId, pageLink, rpcStatusFilter).subscribe( + (responseBody) => { + rpcSubject.next(responseBody); + rpcSubject.complete(); + }, + (rejection: HttpErrorResponse) => { + this.rpcErrorText = null; + this.executingSubjects = []; + + const index = this.executingSubjects.indexOf(rpcSubject); + if (index >= 0) { + this.executingSubjects.splice(index, 1); + } + this.executingRpcRequest = this.executingSubjects.length > 0; + this.subscription.options.callbacks.rpcStateChanged(this.subscription); + if (!this.executingRpcRequest || rejection.status === 504) { + this.subscription.rpcRejection = rejection; + if (rejection.status === 504) { + this.subscription.rpcErrorText = 'Request Timeout.'; + } else { + this.subscription.rpcErrorText = 'Error : ' + rejection.status + ' - ' + rejection.statusText; + const error = this.extractRejectionErrorText(rejection); + if (error) { + this.subscription.rpcErrorText += '
'; + this.subscription.rpcErrorText += error.message || ''; + } + } + this.subscription.callbacks.onRpcFailed(this.subscription); + } + rpcSubject.error(rejection); + } + ); + return rpcSubject.asObservable(); + } + + extractRejectionErrorText(rejection: HttpErrorResponse) { + let error = null; + if (rejection.error) { + error = rejection.error; + try { + error = rejection.error ? JSON.parse(rejection.error) : null; + } catch (e) {} + } + if (error && !error.message) { + error = this.prepareMessageFromData(error); + } else if (error && error.message) { + error = error.message; + } + return error; + } + + prepareMessageFromData(data) { + if (typeof data === 'object' && data.constructor === ArrayBuffer) { + const msg = String.fromCharCode.apply(null, new Uint8Array(data)); + try { + const msgObj = JSON.parse(msg); + if (msgObj.message) { + return msgObj.message; + } else { + return msg; + } + } catch (e) { + return msg; + } + } else { + return data; + } + } + + isEmpty(): Observable { + return this.persistentSubject.pipe( + map((requests) => !requests.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html index a7b1306133..425f7ee37a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html @@ -1,6 +1,6 @@ -
+
{{ error }} @@ -37,3 +37,14 @@
+ +
+
+ {{title}} + +
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss index 293246c79e..a261ee6b71 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts index 697c837a6e..96c36337a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { WidgetContext } from '@home/models/widget-component.models'; import { UtilsService } from '@core/services/utils.service'; @@ -26,11 +26,14 @@ import { DatasourceType, widgetType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { MatSlideToggle } from '@angular/material/slide-toggle'; import { ResizeObserver } from '@juggle/resize-observer'; +import { ThemePalette } from '@angular/material/core/common-behaviors/color'; const switchAspectRation = 2.7893; type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries'; +type SwitchType = 'switch' | 'slide-toggle'; + interface SwitchSettings { initialValue: boolean; title: string; @@ -44,6 +47,8 @@ interface SwitchSettings { requestTimeout: number; requestPersistent: boolean; persistentPollingInterval: number; + labelPosition: 'before' | 'after'; + sliderColor: ThemePalette; } @Component({ @@ -51,28 +56,33 @@ interface SwitchSettings { templateUrl: './switch.component.html', styleUrls: ['./switch.component.scss'] }) -export class SwitchComponent extends PageComponent implements OnInit, OnDestroy { - - @ViewChild('switch', {static: true}) switchElementRef: ElementRef; - @ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef; - @ViewChild('matSlideToggle', {static: true}) matSlideToggleRef: MatSlideToggle; - @ViewChild('onoffContainer', {static: true}) onoffContainerRef: ElementRef; - @ViewChild('onLabel', {static: true}) onLabelRef: ElementRef; - @ViewChild('offLabel', {static: true}) offLabelRef: ElementRef; - @ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef; - @ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef; - @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef; - @ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef; - @ViewChild('switchError', {static: true}) switchErrorRef: ElementRef; +export class SwitchComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('switch', {static: false}) switchElementRef: ElementRef; + @ViewChild('switchContainer', {static: false}) switchContainerRef: ElementRef; + @ViewChild('matSlideToggle', {static: false}) matSlideToggleRef: MatSlideToggle; + @ViewChild('onoffContainer', {static: false}) onoffContainerRef: ElementRef; + @ViewChild('onLabel', {static: false}) onLabelRef: ElementRef; + @ViewChild('offLabel', {static: false}) offLabelRef: ElementRef; + @ViewChild('switchTitleContainer', {static: false}) switchTitleContainerRef: ElementRef; + @ViewChild('switchTitle', {static: false}) switchTitleRef: ElementRef; + @ViewChild('textMeasure', {static: false}) textMeasureRef: ElementRef; + @ViewChild('switchErrorContainer', {static: false}) switchErrorContainerRef: ElementRef; + @ViewChild('switchError', {static: false}) switchErrorRef: ElementRef; @Input() ctx: WidgetContext; + @Input() + switchType: SwitchType = 'switch'; + showTitle = false; value = false; error = ''; title = ''; showOnOffLabels = false; + labelPosition: 'before' | 'after' = 'after'; + sliderColor: ThemePalette = 'accent'; private isSimulated: boolean; private requestTimeout: number; @@ -87,7 +97,7 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy private valueSubscription: IWidgetSubscription; - private executingUpdateValue: boolean; + public executingUpdateValue: boolean; private scheduledValue: boolean; private rpcValue: boolean; @@ -111,22 +121,27 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy } ngOnInit(): void { - this.switchElement = $(this.switchElementRef.nativeElement); - this.switchContainer = $(this.switchContainerRef.nativeElement); - this.matSlideToggle = $(this.matSlideToggleRef._elementRef.nativeElement); - this.onoffContainer = $(this.onoffContainerRef.nativeElement); - this.onLabel = $(this.onLabelRef.nativeElement); - this.offLabel = $(this.offLabelRef.nativeElement); - this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement); - this.switchTitle = $(this.switchTitleRef.nativeElement); - this.textMeasure = $(this.textMeasureRef.nativeElement); - this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement); - this.switchError = $(this.switchErrorRef.nativeElement); - - this.switchResize$ = new ResizeObserver(() => { - this.resize(); - }); - this.switchResize$.observe(this.switchContainerRef.nativeElement); + } + + ngAfterViewInit() { + if (this.switchType === 'switch') { + this.switchElement = $(this.switchElementRef.nativeElement); + this.switchContainer = $(this.switchContainerRef.nativeElement); + this.matSlideToggle = $(this.matSlideToggleRef._elementRef.nativeElement); + this.onoffContainer = $(this.onoffContainerRef.nativeElement); + this.onLabel = $(this.onLabelRef.nativeElement); + this.offLabel = $(this.offLabelRef.nativeElement); + this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement); + this.switchTitle = $(this.switchTitleRef.nativeElement); + this.textMeasure = $(this.textMeasureRef.nativeElement); + this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement); + this.switchError = $(this.switchErrorRef.nativeElement); + + this.switchResize$ = new ResizeObserver(() => { + this.resize(); + }); + this.switchResize$.observe(this.switchContainerRef.nativeElement); + } this.init(); } @@ -145,6 +160,8 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy this.title = isDefined(settings.title) ? settings.title : ''; this.showTitle = !!(this.title && this.title.length); this.showOnOffLabels = isDefined(settings.showOnOffLabels) ? settings.showOnOffLabels : true; + this.labelPosition = isDefined(settings.labelPosition) ? settings.labelPosition : 'after'; + this.sliderColor = isDefined(settings.sliderColor) ? settings.sliderColor : 'accent'; const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; this.setValue(initialValue); @@ -256,7 +273,9 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy private onError(error: string) { this.error = error; - this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); + if (this.switchType === 'switch') { + this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); + } this.ctx.detectChanges(); } @@ -278,6 +297,10 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy }, () => { const errorText = this.ctx.defaultSubscription.rpcErrorText; + if (this.switchType === 'slide-toggle') { + this.ctx.defaultSubscription.rpcErrorText = null; + this.ctx.hideToast(this.ctx.toastTargetId); + } this.onError(errorText); } ); @@ -304,6 +327,10 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy () => { this.executingUpdateValue = false; const errorText = this.ctx.defaultSubscription.rpcErrorText; + if (this.switchType === 'slide-toggle') { + this.ctx.defaultSubscription.rpcErrorText = null; + this.ctx.hideToast(this.ctx.toastTargetId); + } this.onError(errorText); } ); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings.models.ts index f7aac069a3..e5089d3070 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index f90c287111..0747ddffc1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,14 +15,13 @@ /// import { EntityId } from '@shared/models/id/entity-id'; -import { DataKey, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; +import { DataKey, FormattedData, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; import { getDescendantProp, isDefined, isNotEmptyStr } from '@core/utils'; import { AlarmDataInfo, alarmFields } from '@shared/models/alarm.models'; import * as tinycolor_ from 'tinycolor2'; import { Direction, EntityDataSortOrder, EntityKey } from '@shared/models/query/query.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { FormattedData } from '@home/components/widget/lib/maps/map-models'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; @@ -73,6 +72,7 @@ export interface EntityData { export interface EntityColumn extends DataKey { def: string; title: string; + sortable: boolean; entityKey?: EntityKey; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss index 98c3dd67a2..0ef9fa5d12 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html index 8c3b9084b0..02ab76d829 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html @@ -1,6 +1,6 @@ - + {{section.icon}} {{section.name | translate}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss index 3abcc4c738..da8df4b469 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.ts b/ui-ngx/src/app/modules/home/menu/menu-link.component.ts index 58724b89f9..cae14d32b6 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.ts +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html index 0a41e75657..0635205581 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html @@ -1,6 +1,6 @@ + routerLinkActive="tb-active" [routerLinkActiveOptions]="{paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored'}" routerLink="{{section.path}}"> {{section.icon}} {{section.name | translate}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss index 87899b02b5..a076fa77d6 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2021 The Thingsboard Authors + * Copyright © 2016-2022 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts index 6bbc5414fe..76a818c16b 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2021 The Thingsboard Authors +/// Copyright © 2016-2022 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/menu/side-menu.component.html b/ui-ngx/src/app/modules/home/menu/side-menu.component.html index 91275719e9..8a9f07f3c9 100644 --- a/ui-ngx/src/app/modules/home/menu/side-menu.component.html +++ b/ui-ngx/src/app/modules/home/menu/side-menu.component.html @@ -1,6 +1,6 @@
+