diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index e41289e97b..696e84895c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -434,7 +434,7 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D if (!firmware.getType().equals(OtaPackageType.FIRMWARE)) { throw new DataValidationException("Can't assign firmware with type: " + firmware.getType()); } - if (firmware.getData() == null) { + if (firmware.getData() == null && !firmware.hasUrl()) { throw new DataValidationException("Can't assign firmware with empty data!"); } if (!firmware.getDeviceProfileId().equals(deviceProfile.getId())) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 34aa462a6d..9d0e2a00d2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -716,7 +716,7 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe if (!firmware.getType().equals(OtaPackageType.FIRMWARE)) { throw new DataValidationException("Can't assign firmware with type: " + firmware.getType()); } - if (firmware.getData() == null) { + if (firmware.getData() == null && !firmware.hasUrl()) { throw new DataValidationException("Can't assign firmware with empty data!"); } if (!firmware.getDeviceProfileId().equals(device.getDeviceProfileId())) { diff --git a/ui-ngx/src/app/core/http/device-profile.service.ts b/ui-ngx/src/app/core/http/device-profile.service.ts index 7e3183255a..729ba7869b 100644 --- a/ui-ngx/src/app/core/http/device-profile.service.ts +++ b/ui-ngx/src/app/core/http/device-profile.service.ts @@ -14,16 +14,21 @@ /// limitations under the License. /// -import {Injectable} from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {PageLink} from '@shared/models/page/page-link'; -import {defaultHttpOptionsFromConfig, RequestConfig} from './http-utils'; -import {Observable} from 'rxjs'; -import {PageData} from '@shared/models/page/page-data'; -import {DeviceProfile, DeviceProfileInfo, DeviceTransportType} from '@shared/models/device.models'; -import {isDefinedAndNotNull, isEmptyStr} from '@core/utils'; -import {ObjectLwM2M, ServerSecurityConfig} from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; -import {SortOrder} from '@shared/models/page/sort-order'; +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { PageLink } from '@shared/models/page/page-link'; +import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { forkJoin, Observable, of, throwError } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { DeviceProfile, DeviceProfileInfo, DeviceTransportType } from '@shared/models/device.models'; +import { isDefinedAndNotNull, isEmptyStr } from '@core/utils'; +import { ObjectLwM2M, ServerSecurityConfig } from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; +import { SortOrder } from '@shared/models/page/sort-order'; +import { OtaPackageService } from '@core/http/ota-package.service'; +import { OtaUpdateType } from '@shared/models/ota-package.models'; +import { mergeMap } from 'rxjs/operators'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; @Injectable({ providedIn: 'root' @@ -31,7 +36,10 @@ import {SortOrder} from '@shared/models/page/sort-order'; export class DeviceProfileService { constructor( - private http: HttpClient + private http: HttpClient, + private otaPackageService: OtaPackageService, + private dialogService: DialogService, + private translate: TranslateService ) { } @@ -70,6 +78,34 @@ export class DeviceProfileService { ); } + public saveDeviceProfileAndConfirmOtaChange(originDeviceProfile: DeviceProfile, deviceProfile: DeviceProfile, + config?: RequestConfig): Observable { + const tasks: Observable[] = []; + if (originDeviceProfile?.id?.id && originDeviceProfile.firmwareId?.id !== deviceProfile.firmwareId?.id) { + tasks.push(this.otaPackageService.countUpdateDeviceAfterChangePackage(OtaUpdateType.FIRMWARE, deviceProfile.id.id)); + } else { + tasks.push(of(0)); + } + if (originDeviceProfile?.id?.id && originDeviceProfile.softwareId?.id !== deviceProfile.softwareId?.id) { + tasks.push(this.otaPackageService.countUpdateDeviceAfterChangePackage(OtaUpdateType.SOFTWARE, deviceProfile.id.id)); + } else { + tasks.push(of(0)); + } + return forkJoin(tasks).pipe( + mergeMap(([deviceFirmwareUpdate, deviceSoftwareUpdate]) => { + let text = ''; + if (deviceFirmwareUpdate > 0) { + text += this.translate.instant('ota-update.change-firmware', {count: deviceFirmwareUpdate}); + } + if (deviceSoftwareUpdate > 0) { + text += text.length ? ' ' : ''; + text += this.translate.instant('ota-update.change-software', {count: deviceSoftwareUpdate}); + } + return text !== '' ? this.dialogService.confirm('', text, null, this.translate.instant('common.proceed')) : of(true); + }), + mergeMap((update) => update ? this.saveDeviceProfile(deviceProfile, config) : throwError('Canceled saving device profiles'))); + } + public saveDeviceProfile(deviceProfile: DeviceProfile, config?: RequestConfig): Observable { return this.http.post('/api/deviceProfile', deviceProfile, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/core/http/ota-package.service.ts b/ui-ngx/src/app/core/http/ota-package.service.ts index 6afeb6e78d..7e7f09726e 100644 --- a/ui-ngx/src/app/core/http/ota-package.service.ts +++ b/ui-ngx/src/app/core/http/ota-package.service.ts @@ -39,7 +39,7 @@ export class OtaPackageService { } public getOtaPackagesInfoByDeviceProfileId(pageLink: PageLink, deviceProfileId: string, type: OtaUpdateType, - hasData = true, config?: RequestConfig): Observable> { + config?: RequestConfig): Observable> { const url = `/api/otaPackages/${deviceProfileId}/${type}${pageLink.toQuery()}`; return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } @@ -120,4 +120,8 @@ export class OtaPackageService { return this.http.delete(`/api/otaPackage/${otaPackageId}`, defaultHttpOptionsFromConfig(config)); } + public countUpdateDeviceAfterChangePackage(type: OtaUpdateType, deviceProfileId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/devices/count/${type}?deviceProfileId=${deviceProfileId}`, defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index f4ecd52437..539371301e 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html @@ -163,8 +163,34 @@ *matCellDef="let entity; let row = index" [matTooltip]="cellTooltip(entity, column, row)" matTooltipPosition="above" - [innerHTML]="cellContent(entity, column, row)" [ngStyle]="cellStyle(entity, column, row)"> + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index 40a6b0cece..df164adda4 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -43,7 +43,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BaseData, HasId } from '@shared/models/base-data'; import { ActivatedRoute } from '@angular/router'; import { - CellActionDescriptor, + CellActionDescriptor, CellActionDescriptorType, EntityActionTableColumn, EntityColumn, EntityTableColumn, @@ -104,6 +104,8 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn timewindow: Timewindow; dataSource: EntitiesDataSource>; + cellActionType = CellActionDescriptorType; + isDetailsOpen = false; detailsPanelOpened = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts index cba32ddcaf..d6378620f9 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts @@ -280,7 +280,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV editingEntity.additionalInfo = mergeDeep((this.editingEntity as any).additionalInfo, this.entityComponent.entityFormValue()?.additionalInfo); } - this.entitiesTableConfig.saveEntity(editingEntity).subscribe( + this.entitiesTableConfig.saveEntity(editingEntity, this.editingEntity).subscribe( (entity) => { this.entity = entity; this.entityComponent.entity = entity; diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html index bd6f92b175..20f271d660 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html @@ -66,5 +66,5 @@ {{ 'device-profile.device-profile-required' | translate }} - {{ hint | translate }} + {{ hint | translate }} diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.ts index 0efcad9608..58433f01c4 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.ts @@ -90,7 +90,7 @@ export class DeviceProfileDialogComponent extends this.submitted = true; if (this.deviceProfileComponent.entityForm.valid) { this.deviceProfile = {...this.deviceProfile, ...this.deviceProfileComponent.entityFormValue()}; - this.deviceProfileService.saveDeviceProfile(this.deviceProfile).subscribe( + this.deviceProfileService.saveDeviceProfileAndConfirmOtaChange(this.deviceProfile, this.deviceProfile).subscribe( (deviceProfile) => { this.dialogRef.close(deviceProfile); } diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index e6de5dbf84..a1393b0bd2 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts @@ -37,7 +37,7 @@ export type EntityStringFunction> = (entity: T) => str export type EntityVoidFunction> = (entity: T) => void; export type EntityIdsVoidFunction> = (ids: HasUUID[]) => void; export type EntityCountStringFunction = (count: number) => string; -export type EntityTwoWayOperation> = (entity: T) => Observable; +export type EntityTwoWayOperation> = (entity: T, originalEntity?: T) => Observable; export type EntityByIdOperation> = (id: HasUUID) => Observable; export type EntityIdOneWayOperation = (id: HasUUID) => Observable; export type EntityActionFunction> = (action: EntityAction) => boolean; @@ -48,6 +48,9 @@ export type CellContentFunction> = (entity: T, key: st export type CellTooltipFunction> = (entity: T, key: string) => string | undefined; export type HeaderCellStyleFunction> = (key: string) => object; export type CellStyleFunction> = (entity: T, key: string) => object; +export type CopyCellContent> = (entity: T, key: string, length: number) => object; + +export enum CellActionDescriptorType { 'DEFAULT', 'COPY_BUTTON'} export interface CellActionDescriptor> { name: string; @@ -56,7 +59,8 @@ export interface CellActionDescriptor> { mdiIcon?: string; style?: any; isEnabled: (entity: T) => boolean; - onAction: ($event: MouseEvent, entity: T) => void; + onAction: ($event: MouseEvent, entity: T) => any; + type?: CellActionDescriptorType; } export interface GroupActionDescriptor> { @@ -95,7 +99,8 @@ export class EntityTableColumn> extends BaseEntityTabl public sortable: boolean = true, public headerCellStyleFunction: HeaderCellStyleFunction = () => ({}), public cellTooltipFunction: CellTooltipFunction = () => undefined, - public isNumberColumn: boolean = false) { + public isNumberColumn: boolean = false, + public actionCell: CellActionDescriptor = null) { super('content', key, title, width, sortable); } } @@ -173,7 +178,7 @@ export class EntityTableConfig, P extends PageLink = P deleteEntitiesTitle: EntityCountStringFunction = () => ''; deleteEntitiesContent: EntityCountStringFunction = () => ''; loadEntity: EntityByIdOperation = () => of(); - saveEntity: EntityTwoWayOperation = (entity) => of(entity); + saveEntity: EntityTwoWayOperation = (entity, originalEntity) => of(entity); deleteEntity: EntityIdOneWayOperation = () => of(); entitiesFetchFunction: EntitiesFetchFunction = () => of(emptyPageData()); onEntityAction: EntityActionFunction = () => false; diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts index 49a256dd55..98281c1f88 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts @@ -104,7 +104,8 @@ export class DeviceProfilesTableConfigResolver implements Resolve this.deviceProfileService.getDeviceProfiles(pageLink); this.config.loadEntity = id => this.deviceProfileService.getDeviceProfile(id.id); - this.config.saveEntity = deviceProfile => this.deviceProfileService.saveDeviceProfile(deviceProfile); + this.config.saveEntity = (deviceProfile, originDeviceProfile) => + this.deviceProfileService.saveDeviceProfileAndConfirmOtaChange(originDeviceProfile, deviceProfile); this.config.deleteEntity = id => this.deviceProfileService.deleteDeviceProfile(id.id); this.config.onEntityAction = action => this.onDeviceProfileAction(action); this.config.deleteEnabled = (deviceProfile) => deviceProfile && !deviceProfile.default; diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts index b22d43da46..dcff8b7964 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts @@ -17,6 +17,7 @@ import { Injectable } from '@angular/core'; import { Resolve } from '@angular/router'; import { + CellActionDescriptorType, DateEntityTableColumn, EntityTableColumn, EntityTableConfig @@ -35,6 +36,8 @@ import { PageLink } from '@shared/models/page/page-link'; import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; @Injectable() export class OtaUpdateTableConfigResolve implements Resolve> { @@ -44,6 +47,7 @@ export class OtaUpdateTableConfigResolve implements Resolve, private otaPackageService: OtaPackageService, private fileSize: FileSizePipe) { this.config.entityType = EntityType.OTA_PACKAGE; @@ -55,25 +59,50 @@ export class OtaUpdateTableConfigResolve implements Resolve('createdTime', 'common.created-time', this.datePipe, '150px'), - new EntityTableColumn('title', 'ota-update.title', '25%'), - new EntityTableColumn('version', 'ota-update.version', '25%'), - new EntityTableColumn('type', 'ota-update.package-type', '25%', entity => { + new EntityTableColumn('title', 'ota-update.title', '20%'), + new EntityTableColumn('version', 'ota-update.version', '20%'), + new EntityTableColumn('type', 'ota-update.package-type', '20%', entity => { return this.translate.instant(OtaUpdateTypeTranslationMap.get(entity.type)); }), - new EntityTableColumn('fileName', 'ota-update.file-name', '25%'), + new EntityTableColumn('url', 'ota-update.direct-url', '20%', entity => { + return entity.url && entity.url.length > 20 ? `${entity.url.slice(0, 20)}…` : ''; + }, () => ({}), true, () => ({}), () => undefined, false, + { + name: this.translate.instant('ota-update.copy-direct-url'), + icon: 'content_paste', + style: { + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: (otaPackage) => !!otaPackage.url, + onAction: ($event, entity) => entity.url, + type: CellActionDescriptorType.COPY_BUTTON + }), + new EntityTableColumn('fileName', 'ota-update.file-name', '20%'), new EntityTableColumn('dataSize', 'ota-update.file-size', '70px', entity => { - return this.fileSize.transform(entity.dataSize || 0); + return entity.dataSize ? this.fileSize.transform(entity.dataSize) : ''; }), - new EntityTableColumn('checksum', 'ota-update.checksum', '540px', entity => { - return `${ChecksumAlgorithmTranslationMap.get(entity.checksumAlgorithm)}: ${entity.checksum}`; - }, () => ({}), false) + new EntityTableColumn('checksum', 'ota-update.checksum', '220px', entity => { + return entity.checksum ? this.checksumText(entity) : ''; + }, () => ({}), true, () => ({}), () => undefined, false, + { + name: this.translate.instant('ota-update.copy-checksum'), + icon: 'content_paste', + style: { + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: (otaPackage) => !!otaPackage.checksum, + onAction: ($event, entity) => entity.checksum, + type: CellActionDescriptorType.COPY_BUTTON + }) ); this.config.cellActionDescriptors.push( { name: this.translate.instant('ota-update.download'), icon: 'file_download', - isEnabled: (otaPackage) => otaPackage.hasData, + isEnabled: (otaPackage) => otaPackage.hasData && !otaPackage.url, onAction: ($event, entity) => this.exportPackage($event, entity) } ); @@ -101,7 +130,19 @@ export class OtaUpdateTableConfigResolve implements Resolve 20) { + text = `${text.slice(0, 20)}…`; + } + return text; } onPackageAction(action: EntityAction): boolean { diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html index 77a68f72e5..0f2662a251 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html @@ -17,7 +17,7 @@ -->
+
-
+
ota-update.title @@ -69,6 +77,7 @@ -
ota-update.warning-after-save-no-edit
-
- - ota-update.checksum-algorithm - - - {{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }} - - - - - ota-update.checksum - - -
- - +
ota-update.warning-after-save-no-edit
+ + Upload binary file + Use external URL +
-
-
+
+
+ + + + {{ 'ota-update.auto-generate-checksum' | translate }} + +
+
- ota-update.file-name - + ota-update.checksum-algorithm + + + {{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }} + + - ota-update.file-size-bytes - - - - ota-update.content-type - + ota-update.checksum + + ota-update.checksum-hint
+
+
+ + ota-update.file-name + + + + ota-update.file-size-bytes + + + + ota-update.content-type + + +
+
+
+
+ + ota-update.direct-url + + + ota-update.direct-url-required + +
diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts index fd4cd7ae27..1182e6c40a 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts @@ -30,6 +30,8 @@ import { OtaUpdateTypeTranslationMap } from '@shared/models/ota-package.models'; import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { filter, takeUntil } from 'rxjs/operators'; +import { isNotEmptyStr } from '@core/utils'; @Component({ selector: 'tb-ota-update', @@ -52,6 +54,26 @@ export class OtaUpdateComponent extends EntityComponent implements O super(store, fb, entityValue, entitiesTableConfigValue); } + ngOnInit() { + super.ngOnInit(); + this.entityForm.get('resource').valueChanges.pipe( + filter(() => this.isAdd), + takeUntil(this.destroy$) + ).subscribe((resource) => { + if (resource === 'file') { + this.entityForm.get('url').clearValidators(); + this.entityForm.get('file').setValidators(Validators.required); + this.entityForm.get('url').updateValueAndValidity({emitEvent: false}); + this.entityForm.get('file').updateValueAndValidity({emitEvent: false}); + } else { + this.entityForm.get('file').clearValidators(); + this.entityForm.get('url').setValidators(Validators.required); + this.entityForm.get('file').updateValueAndValidity({emitEvent: false}); + this.entityForm.get('url').updateValueAndValidity({emitEvent: false}); + } + }); + } + ngOnDestroy() { super.ngOnDestroy(); this.destroy$.next(); @@ -74,6 +96,8 @@ export class OtaUpdateComponent extends EntityComponent implements O deviceProfileId: [entity ? entity.deviceProfileId : null, Validators.required], checksumAlgorithm: [entity && entity.checksumAlgorithm ? entity.checksumAlgorithm : ChecksumAlgorithm.SHA256], checksum: [entity ? entity.checksum : '', Validators.maxLength(1020)], + url: [entity ? entity.url : ''], + resource: ['file'], additionalInfo: this.fb.group( { description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], @@ -82,6 +106,7 @@ export class OtaUpdateComponent extends EntityComponent implements O }); if (this.isAdd) { form.addControl('file', this.fb.control(null, Validators.required)); + form.addControl('generateChecksum', this.fb.control(true)); } else { form.addControl('fileName', this.fb.control(null)); form.addControl('dataSize', this.fb.control(null)); @@ -101,6 +126,8 @@ export class OtaUpdateComponent extends EntityComponent implements O fileName: entity.fileName, dataSize: entity.dataSize, contentType: entity.contentType, + url: entity.url, + resource: isNotEmptyStr(entity.url) ? 'url' : 'file', additionalInfo: { description: entity.additionalInfo ? entity.additionalInfo.description : '' } @@ -108,8 +135,6 @@ export class OtaUpdateComponent extends EntityComponent implements O if (!this.isAdd && this.entityForm.enabled) { this.entityForm.disable({emitEvent: false}); this.entityForm.get('additionalInfo').enable({emitEvent: false}); - // this.entityForm.get('dataSize').disable({emitEvent: false}); - // this.entityForm.get('contentType').disable({emitEvent: false}); } } @@ -134,4 +159,21 @@ export class OtaUpdateComponent extends EntityComponent implements O horizontalPosition: 'right' })); } + + onPackageDirectUrlCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('ota-update.checksum-copied-message'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + prepareFormValue(formValue: any): any { + delete formValue.resource; + delete formValue.generateChecksum; + return super.prepareFormValue(formValue); + } } diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.html b/ui-ngx/src/app/shared/components/button/copy-button.component.html new file mode 100644 index 0000000000..20dd7d06ed --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.html @@ -0,0 +1,26 @@ + + diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.scss b/ui-ngx/src/app/shared/components/button/copy-button.component.scss new file mode 100644 index 0000000000..0e5f5de8d9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2021 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 { + .mat-icon-button{ + height: 32px; + width: 32px; + line-height: 32px; + .mat-icon.copied{ + color: #00C851 !important; + } + } + &:hover{ + .mat-icon{ + color: #28567E !important; + } + } +} diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.ts b/ui-ngx/src/app/shared/components/button/copy-button.component.ts new file mode 100644 index 0000000000..8d5d72efe3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.ts @@ -0,0 +1,95 @@ +/// +/// Copyright © 2016-2021 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, EventEmitter, Input, Output } from '@angular/core'; +import { ClipboardService } from 'ngx-clipboard'; +import { TooltipPosition } from '@angular/material/tooltip'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-copy-button', + styleUrls: ['copy-button.component.scss'], + templateUrl: './copy-button.component.html' +}) +export class CopyButtonComponent { + + private copedIcon = ''; + private timer; + + copied = false; + + @Input() + copyText: string; + + @Input() + disabled = false; + + @Input() + mdiIcon: string; + + @Input() + icon: string; + + @Input() + tooltipText: string; + + @Input() + tooltipPosition: TooltipPosition; + + @Input() + style: {[key: string]: any} = {}; + + @Output() + successCopied = new EventEmitter(); + + constructor(private clipboardService: ClipboardService, + private translate: TranslateService, + private cd: ChangeDetectorRef) { + } + + copy($event: Event): void { + $event.stopPropagation(); + if (this.timer) { + clearTimeout(this.timer); + } + this.clipboardService.copy(this.copyText); + this.successCopied.emit(this.copyText); + this.copedIcon = 'done'; + this.copied = true; + this.timer = setTimeout(() => { + this.copedIcon = null; + this.copied = false; + this.cd.detectChanges(); + }, 1500); + } + + get iconSymbol(): string { + return this.copedIcon || this.icon; + } + + get mdiIconSymbol(): string { + return this.copedIcon ? '' : this.mdiIcon; + } + + get matTooltipText(): string { + return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText; + } + + get matTooltipPosition(): TooltipPosition { + return this.copied ? 'below' : this.tooltipPosition; + } + +} diff --git a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts index 25df909a8c..bb3be21fa8 100644 --- a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts @@ -216,7 +216,7 @@ export class OtaPackageAutocompleteComponent implements ControlValueAccessor, On direction: Direction.ASC }); return this.otaPackageService.getOtaPackagesInfoByDeviceProfileId(pageLink, this.deviceProfileId, this.type, - true, {ignoreLoading: true}).pipe( + {ignoreLoading: true}).pipe( map((data) => data && data.data.length ? data.data : null) ); } diff --git a/ui-ngx/src/app/shared/models/ota-package.models.ts b/ui-ngx/src/app/shared/models/ota-package.models.ts index 22efc881b0..b6fecfc2c5 100644 --- a/ui-ngx/src/app/shared/models/ota-package.models.ts +++ b/ui-ngx/src/app/shared/models/ota-package.models.ts @@ -87,6 +87,7 @@ export interface OtaPackageInfo extends BaseData { title?: string; version?: string; hasData?: boolean; + url?: string; fileName: string; checksum?: string; checksumAlgorithm?: ChecksumAlgorithm; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 5e71d4c44a..f2bcdc97a4 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -143,6 +143,7 @@ import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component'; import { MAT_DATE_LOCALE } from '@angular/material/core'; +import { CopyButtonComponent } from '@shared/components/button/copy-button.component'; @NgModule({ providers: [ @@ -240,7 +241,8 @@ import { MAT_DATE_LOCALE } from '@angular/material/core'; EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent + WidgetsBundleSearchComponent, + CopyButtonComponent ], imports: [ CommonModule, @@ -412,7 +414,8 @@ import { MAT_DATE_LOCALE } from '@angular/material/core'; EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent + WidgetsBundleSearchComponent, + CopyButtonComponent ] }) export class SharedModule { } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 8e417ec1fb..cb5165c670 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -575,7 +575,8 @@ "enter-password": "Enter password", "enter-search": "Enter search", "created-time": "Created time", - "loading": "Loading..." + "loading": "Loading...", + "proceed": "Proceed" }, "content-type": { "json": "Json", @@ -2157,21 +2158,30 @@ "assign-firmware-required": "Assigned firmware is required", "assign-software": "Assigned software", "assign-software-required": "Assigned software is required", + "auto-generate-checksum": "Auto-generate checksum", "checksum": "Checksum", + "checksum-hint": "If checksum is empty, it will be generated automatically", "checksum-algorithm": "Checksum algorithm", "checksum-copied-message": "Package checksum has been copied to clipboard", - "chose-compatible-device-profile": "Choose compatible device profile", + "change-firmware": "Change of the firmware may cause update of { count, plural, 1 {1 device} other {# devices} }.", + "change-software": "Change of the software may cause update of { count, plural, 1 {1 device} other {# devices} }.", + "chose-compatible-device-profile": "The uploaded package will be available only for devices with the chosen profile.", "chose-firmware-distributed-device": "Choose firmware that will be distributed to the devices", "chose-software-distributed-device": "Choose software that will be distributed to the devices", "content-type": "Content type", "copy-checksum": "Copy checksum", + "copy-direct-url": "Copy direct URL", "copyId": "Copy package Id", + "copied": "Copied!", "delete": "Delete package", "delete-ota-update-text": "Be careful, after the confirmation the OTA update will become unrecoverable.", "delete-ota-update-title": "Are you sure you want to delete the OTA update '{{title}}'?", "delete-ota-updates-text": "Be careful, after the confirmation all selected OTA updates will be removed.", "delete-ota-updates-title": "Are you sure you want to delete { count, plural, 1 {1 OTA update} other {# OTA updates} }?", "description": "Description", + "direct-url": "Direct URL", + "direct-url-copied-message": "Package direct URL has been copied to clipboard", + "direct-url-required": "Direct URL is required", "download": "Download package", "drop-file": "Drop a package file or click to select a file to upload.", "file-name": "File name",