Merge pull request #4695 from vvlladd28/improvement/ota-updates/select

[WIP] UI: Refactoring OTA update table and add supported OTA package external URL
This commit is contained in:
Igor Kulikov 2021-06-07 14:06:58 +03:00 committed by GitHub
commit f96ea353f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 430 additions and 75 deletions

View File

@ -434,7 +434,7 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D
if (!firmware.getType().equals(OtaPackageType.FIRMWARE)) { if (!firmware.getType().equals(OtaPackageType.FIRMWARE)) {
throw new DataValidationException("Can't assign firmware with type: " + firmware.getType()); 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!"); throw new DataValidationException("Can't assign firmware with empty data!");
} }
if (!firmware.getDeviceProfileId().equals(deviceProfile.getId())) { if (!firmware.getDeviceProfileId().equals(deviceProfile.getId())) {

View File

@ -716,7 +716,7 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe
if (!firmware.getType().equals(OtaPackageType.FIRMWARE)) { if (!firmware.getType().equals(OtaPackageType.FIRMWARE)) {
throw new DataValidationException("Can't assign firmware with type: " + firmware.getType()); 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!"); throw new DataValidationException("Can't assign firmware with empty data!");
} }
if (!firmware.getDeviceProfileId().equals(device.getDeviceProfileId())) { if (!firmware.getDeviceProfileId().equals(device.getDeviceProfileId())) {

View File

@ -14,16 +14,21 @@
/// limitations under the License. /// limitations under the License.
/// ///
import {Injectable} from '@angular/core'; import { Injectable } from '@angular/core';
import {HttpClient} from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import {PageLink} from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import {defaultHttpOptionsFromConfig, RequestConfig} from './http-utils'; import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import {Observable} from 'rxjs'; import { forkJoin, Observable, of, throwError } from 'rxjs';
import {PageData} from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import {DeviceProfile, DeviceProfileInfo, DeviceTransportType} from '@shared/models/device.models'; import { DeviceProfile, DeviceProfileInfo, DeviceTransportType } from '@shared/models/device.models';
import {isDefinedAndNotNull, isEmptyStr} from '@core/utils'; import { isDefinedAndNotNull, isEmptyStr } from '@core/utils';
import {ObjectLwM2M, ServerSecurityConfig} from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; import { ObjectLwM2M, ServerSecurityConfig } from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models';
import {SortOrder} from '@shared/models/page/sort-order'; 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({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -31,7 +36,10 @@ import {SortOrder} from '@shared/models/page/sort-order';
export class DeviceProfileService { export class DeviceProfileService {
constructor( 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<DeviceProfile> {
const tasks: Observable<number>[] = [];
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<DeviceProfile> { public saveDeviceProfile(deviceProfile: DeviceProfile, config?: RequestConfig): Observable<DeviceProfile> {
return this.http.post<DeviceProfile>('/api/deviceProfile', deviceProfile, defaultHttpOptionsFromConfig(config)); return this.http.post<DeviceProfile>('/api/deviceProfile', deviceProfile, defaultHttpOptionsFromConfig(config));
} }

View File

@ -39,7 +39,7 @@ export class OtaPackageService {
} }
public getOtaPackagesInfoByDeviceProfileId(pageLink: PageLink, deviceProfileId: string, type: OtaUpdateType, public getOtaPackagesInfoByDeviceProfileId(pageLink: PageLink, deviceProfileId: string, type: OtaUpdateType,
hasData = true, config?: RequestConfig): Observable<PageData<OtaPackageInfo>> { config?: RequestConfig): Observable<PageData<OtaPackageInfo>> {
const url = `/api/otaPackages/${deviceProfileId}/${type}${pageLink.toQuery()}`; const url = `/api/otaPackages/${deviceProfileId}/${type}${pageLink.toQuery()}`;
return this.http.get<PageData<OtaPackageInfo>>(url, defaultHttpOptionsFromConfig(config)); return this.http.get<PageData<OtaPackageInfo>>(url, defaultHttpOptionsFromConfig(config));
} }
@ -120,4 +120,8 @@ export class OtaPackageService {
return this.http.delete(`/api/otaPackage/${otaPackageId}`, defaultHttpOptionsFromConfig(config)); return this.http.delete(`/api/otaPackage/${otaPackageId}`, defaultHttpOptionsFromConfig(config));
} }
public countUpdateDeviceAfterChangePackage(type: OtaUpdateType, deviceProfileId: string, config?: RequestConfig): Observable<number> {
return this.http.get<number>(`/api/devices/count/${type}?deviceProfileId=${deviceProfileId}`, defaultHttpOptionsFromConfig(config));
}
} }

View File

@ -163,8 +163,34 @@
*matCellDef="let entity; let row = index" *matCellDef="let entity; let row = index"
[matTooltip]="cellTooltip(entity, column, row)" [matTooltip]="cellTooltip(entity, column, row)"
matTooltipPosition="above" matTooltipPosition="above"
[innerHTML]="cellContent(entity, column, row)"
[ngStyle]="cellStyle(entity, column, row)"> [ngStyle]="cellStyle(entity, column, row)">
<span [innerHTML]="cellContent(entity, column, row)"></span>
<ng-template [ngIf]="column.actionCell">
<ng-container [ngSwitch]="column.actionCell.type">
<ng-template [ngSwitchCase]="cellActionType.COPY_BUTTON">
<tb-copy-button
[disabled]="isLoading$ | async"
[fxShow]="column.actionCell.isEnabled(entity)"
[copyText]="column.actionCell.onAction(null, entity)"
tooltipText="{{ column.actionCell.nameFunction ? column.actionCell.nameFunction(entity) : column.actionCell.name }}"
tooltipPosition="above"
[icon]="column.actionCell.icon"
[mdiIcon]="column.actionCell.mdiIcon" [style]="column.actionCell.style">
</tb-copy-button>
</ng-template>
<ng-template ngSwitchDefault>
<button mat-icon-button [disabled]="isLoading$ | async"
[fxShow]="column.actionCell.isEnabled(entity)"
matTooltip="{{ column.actionCell.nameFunction ? column.actionCell.nameFunction(entity) : column.actionCell.name }}"
matTooltipPosition="above"
(click)="column.actionCell.onAction($event, entity)">
<mat-icon [svgIcon]="column.actionCell.mdiIcon" [ngStyle]="column.actionCell.style">
{{column.actionCell.icon}}
</mat-icon>
</button>
</ng-template>
</ng-container>
</ng-template>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container [matColumnDef]="column.key" *ngFor="let column of actionColumns; trackBy: trackByColumnKey;"> <ng-container [matColumnDef]="column.key" *ngFor="let column of actionColumns; trackBy: trackByColumnKey;">

View File

@ -43,7 +43,7 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseData, HasId } from '@shared/models/base-data'; import { BaseData, HasId } from '@shared/models/base-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { import {
CellActionDescriptor, CellActionDescriptor, CellActionDescriptorType,
EntityActionTableColumn, EntityActionTableColumn,
EntityColumn, EntityColumn,
EntityTableColumn, EntityTableColumn,
@ -104,6 +104,8 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn
timewindow: Timewindow; timewindow: Timewindow;
dataSource: EntitiesDataSource<BaseData<HasId>>; dataSource: EntitiesDataSource<BaseData<HasId>>;
cellActionType = CellActionDescriptorType;
isDetailsOpen = false; isDetailsOpen = false;
detailsPanelOpened = new EventEmitter<boolean>(); detailsPanelOpened = new EventEmitter<boolean>();

View File

@ -280,7 +280,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV
editingEntity.additionalInfo = editingEntity.additionalInfo =
mergeDeep((this.editingEntity as any).additionalInfo, this.entityComponent.entityFormValue()?.additionalInfo); mergeDeep((this.editingEntity as any).additionalInfo, this.entityComponent.entityFormValue()?.additionalInfo);
} }
this.entitiesTableConfig.saveEntity(editingEntity).subscribe( this.entitiesTableConfig.saveEntity(editingEntity, this.editingEntity).subscribe(
(entity) => { (entity) => {
this.entity = entity; this.entity = entity;
this.entityComponent.entity = entity; this.entityComponent.entity = entity;

View File

@ -66,5 +66,5 @@
<mat-error *ngIf="selectDeviceProfileFormGroup.get('deviceProfile').hasError('required')"> <mat-error *ngIf="selectDeviceProfileFormGroup.get('deviceProfile').hasError('required')">
{{ 'device-profile.device-profile-required' | translate }} {{ 'device-profile.device-profile-required' | translate }}
</mat-error> </mat-error>
<mat-hint *ngIf="!!hint">{{ hint | translate }}</mat-hint> <mat-hint *ngIf="hint && !disabled">{{ hint | translate }}</mat-hint>
</mat-form-field> </mat-form-field>

View File

@ -90,7 +90,7 @@ export class DeviceProfileDialogComponent extends
this.submitted = true; this.submitted = true;
if (this.deviceProfileComponent.entityForm.valid) { if (this.deviceProfileComponent.entityForm.valid) {
this.deviceProfile = {...this.deviceProfile, ...this.deviceProfileComponent.entityFormValue()}; this.deviceProfile = {...this.deviceProfile, ...this.deviceProfileComponent.entityFormValue()};
this.deviceProfileService.saveDeviceProfile(this.deviceProfile).subscribe( this.deviceProfileService.saveDeviceProfileAndConfirmOtaChange(this.deviceProfile, this.deviceProfile).subscribe(
(deviceProfile) => { (deviceProfile) => {
this.dialogRef.close(deviceProfile); this.dialogRef.close(deviceProfile);
} }

View File

@ -37,7 +37,7 @@ export type EntityStringFunction<T extends BaseData<HasId>> = (entity: T) => str
export type EntityVoidFunction<T extends BaseData<HasId>> = (entity: T) => void; export type EntityVoidFunction<T extends BaseData<HasId>> = (entity: T) => void;
export type EntityIdsVoidFunction<T extends BaseData<HasId>> = (ids: HasUUID[]) => void; export type EntityIdsVoidFunction<T extends BaseData<HasId>> = (ids: HasUUID[]) => void;
export type EntityCountStringFunction = (count: number) => string; export type EntityCountStringFunction = (count: number) => string;
export type EntityTwoWayOperation<T extends BaseData<HasId>> = (entity: T) => Observable<T>; export type EntityTwoWayOperation<T extends BaseData<HasId>> = (entity: T, originalEntity?: T) => Observable<T>;
export type EntityByIdOperation<T extends BaseData<HasId>> = (id: HasUUID) => Observable<T>; export type EntityByIdOperation<T extends BaseData<HasId>> = (id: HasUUID) => Observable<T>;
export type EntityIdOneWayOperation = (id: HasUUID) => Observable<any>; export type EntityIdOneWayOperation = (id: HasUUID) => Observable<any>;
export type EntityActionFunction<T extends BaseData<HasId>> = (action: EntityAction<T>) => boolean; export type EntityActionFunction<T extends BaseData<HasId>> = (action: EntityAction<T>) => boolean;
@ -48,6 +48,9 @@ export type CellContentFunction<T extends BaseData<HasId>> = (entity: T, key: st
export type CellTooltipFunction<T extends BaseData<HasId>> = (entity: T, key: string) => string | undefined; export type CellTooltipFunction<T extends BaseData<HasId>> = (entity: T, key: string) => string | undefined;
export type HeaderCellStyleFunction<T extends BaseData<HasId>> = (key: string) => object; export type HeaderCellStyleFunction<T extends BaseData<HasId>> = (key: string) => object;
export type CellStyleFunction<T extends BaseData<HasId>> = (entity: T, key: string) => object; export type CellStyleFunction<T extends BaseData<HasId>> = (entity: T, key: string) => object;
export type CopyCellContent<T extends BaseData<HasId>> = (entity: T, key: string, length: number) => object;
export enum CellActionDescriptorType { 'DEFAULT', 'COPY_BUTTON'}
export interface CellActionDescriptor<T extends BaseData<HasId>> { export interface CellActionDescriptor<T extends BaseData<HasId>> {
name: string; name: string;
@ -56,7 +59,8 @@ export interface CellActionDescriptor<T extends BaseData<HasId>> {
mdiIcon?: string; mdiIcon?: string;
style?: any; style?: any;
isEnabled: (entity: T) => boolean; isEnabled: (entity: T) => boolean;
onAction: ($event: MouseEvent, entity: T) => void; onAction: ($event: MouseEvent, entity: T) => any;
type?: CellActionDescriptorType;
} }
export interface GroupActionDescriptor<T extends BaseData<HasId>> { export interface GroupActionDescriptor<T extends BaseData<HasId>> {
@ -95,7 +99,8 @@ export class EntityTableColumn<T extends BaseData<HasId>> extends BaseEntityTabl
public sortable: boolean = true, public sortable: boolean = true,
public headerCellStyleFunction: HeaderCellStyleFunction<T> = () => ({}), public headerCellStyleFunction: HeaderCellStyleFunction<T> = () => ({}),
public cellTooltipFunction: CellTooltipFunction<T> = () => undefined, public cellTooltipFunction: CellTooltipFunction<T> = () => undefined,
public isNumberColumn: boolean = false) { public isNumberColumn: boolean = false,
public actionCell: CellActionDescriptor<T> = null) {
super('content', key, title, width, sortable); super('content', key, title, width, sortable);
} }
} }
@ -173,7 +178,7 @@ export class EntityTableConfig<T extends BaseData<HasId>, P extends PageLink = P
deleteEntitiesTitle: EntityCountStringFunction = () => ''; deleteEntitiesTitle: EntityCountStringFunction = () => '';
deleteEntitiesContent: EntityCountStringFunction = () => ''; deleteEntitiesContent: EntityCountStringFunction = () => '';
loadEntity: EntityByIdOperation<T | L> = () => of(); loadEntity: EntityByIdOperation<T | L> = () => of();
saveEntity: EntityTwoWayOperation<T> = (entity) => of(entity); saveEntity: EntityTwoWayOperation<T> = (entity, originalEntity) => of(entity);
deleteEntity: EntityIdOneWayOperation = () => of(); deleteEntity: EntityIdOneWayOperation = () => of();
entitiesFetchFunction: EntitiesFetchFunction<L, P> = () => of(emptyPageData<L>()); entitiesFetchFunction: EntitiesFetchFunction<L, P> = () => of(emptyPageData<L>());
onEntityAction: EntityActionFunction<T> = () => false; onEntityAction: EntityActionFunction<T> = () => false;

View File

@ -104,7 +104,8 @@ export class DeviceProfilesTableConfigResolver implements Resolve<EntityTableCon
this.config.entitiesFetchFunction = pageLink => this.deviceProfileService.getDeviceProfiles(pageLink); this.config.entitiesFetchFunction = pageLink => this.deviceProfileService.getDeviceProfiles(pageLink);
this.config.loadEntity = id => this.deviceProfileService.getDeviceProfile(id.id); 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.deleteEntity = id => this.deviceProfileService.deleteDeviceProfile(id.id);
this.config.onEntityAction = action => this.onDeviceProfileAction(action); this.config.onEntityAction = action => this.onDeviceProfileAction(action);
this.config.deleteEnabled = (deviceProfile) => deviceProfile && !deviceProfile.default; this.config.deleteEnabled = (deviceProfile) => deviceProfile && !deviceProfile.default;

View File

@ -17,6 +17,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router'; import { Resolve } from '@angular/router';
import { import {
CellActionDescriptorType,
DateEntityTableColumn, DateEntityTableColumn,
EntityTableColumn, EntityTableColumn,
EntityTableConfig EntityTableConfig
@ -35,6 +36,8 @@ import { PageLink } from '@shared/models/page/page-link';
import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component';
import { EntityAction } from '@home/models/entity/entity-component.models'; import { EntityAction } from '@home/models/entity/entity-component.models';
import { FileSizePipe } from '@shared/pipe/file-size.pipe'; import { FileSizePipe } from '@shared/pipe/file-size.pipe';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@Injectable() @Injectable()
export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<OtaPackage, PageLink, OtaPackageInfo>> { export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<OtaPackage, PageLink, OtaPackageInfo>> {
@ -44,6 +47,7 @@ export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<Ot
constructor(private translate: TranslateService, constructor(private translate: TranslateService,
private datePipe: DatePipe, private datePipe: DatePipe,
private store: Store<AppState>,
private otaPackageService: OtaPackageService, private otaPackageService: OtaPackageService,
private fileSize: FileSizePipe) { private fileSize: FileSizePipe) {
this.config.entityType = EntityType.OTA_PACKAGE; this.config.entityType = EntityType.OTA_PACKAGE;
@ -55,25 +59,50 @@ export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<Ot
this.config.columns.push( this.config.columns.push(
new DateEntityTableColumn<OtaPackageInfo>('createdTime', 'common.created-time', this.datePipe, '150px'), new DateEntityTableColumn<OtaPackageInfo>('createdTime', 'common.created-time', this.datePipe, '150px'),
new EntityTableColumn<OtaPackageInfo>('title', 'ota-update.title', '25%'), new EntityTableColumn<OtaPackageInfo>('title', 'ota-update.title', '20%'),
new EntityTableColumn<OtaPackageInfo>('version', 'ota-update.version', '25%'), new EntityTableColumn<OtaPackageInfo>('version', 'ota-update.version', '20%'),
new EntityTableColumn<OtaPackageInfo>('type', 'ota-update.package-type', '25%', entity => { new EntityTableColumn<OtaPackageInfo>('type', 'ota-update.package-type', '20%', entity => {
return this.translate.instant(OtaUpdateTypeTranslationMap.get(entity.type)); return this.translate.instant(OtaUpdateTypeTranslationMap.get(entity.type));
}), }),
new EntityTableColumn<OtaPackageInfo>('fileName', 'ota-update.file-name', '25%'), new EntityTableColumn<OtaPackageInfo>('url', 'ota-update.direct-url', '20%', entity => {
new EntityTableColumn<OtaPackageInfo>('dataSize', 'ota-update.file-size', '70px', entity => { return entity.url && entity.url.length > 20 ? `${entity.url.slice(0, 20)}` : '';
return this.fileSize.transform(entity.dataSize || 0); }, () => ({}), 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<OtaPackageInfo>('checksum', 'ota-update.checksum', '540px', entity => { new EntityTableColumn<OtaPackageInfo>('fileName', 'ota-update.file-name', '20%'),
return `${ChecksumAlgorithmTranslationMap.get(entity.checksumAlgorithm)}: ${entity.checksum}`; new EntityTableColumn<OtaPackageInfo>('dataSize', 'ota-update.file-size', '70px', entity => {
}, () => ({}), false) return entity.dataSize ? this.fileSize.transform(entity.dataSize) : '';
}),
new EntityTableColumn<OtaPackageInfo>('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( this.config.cellActionDescriptors.push(
{ {
name: this.translate.instant('ota-update.download'), name: this.translate.instant('ota-update.download'),
icon: 'file_download', icon: 'file_download',
isEnabled: (otaPackage) => otaPackage.hasData, isEnabled: (otaPackage) => otaPackage.hasData && !otaPackage.url,
onAction: ($event, entity) => this.exportPackage($event, entity) onAction: ($event, entity) => this.exportPackage($event, entity)
} }
); );
@ -101,8 +130,20 @@ export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<Ot
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
if (otaPackageInfo.url) {
window.open(otaPackageInfo.url, '_blank');
} else {
this.otaPackageService.downloadOtaPackage(otaPackageInfo.id.id).subscribe(); this.otaPackageService.downloadOtaPackage(otaPackageInfo.id.id).subscribe();
} }
}
checksumText(entity): string {
let text = `${ChecksumAlgorithmTranslationMap.get(entity.checksumAlgorithm)}: ${entity.checksum}`;
if (text.length > 20) {
text = `${text.slice(0, 20)}`;
}
return text;
}
onPackageAction(action: EntityAction<OtaPackageInfo>): boolean { onPackageAction(action: EntityAction<OtaPackageInfo>): boolean {
switch (action.action) { switch (action.action) {

View File

@ -17,7 +17,7 @@
--> -->
<div class="tb-details-buttons" fxLayout.xs="column"> <div class="tb-details-buttons" fxLayout.xs="column">
<button mat-raised-button color="primary" fxFlex.xs <button mat-raised-button color="primary" fxFlex.xs
[disabled]="(isLoading$ | async) || !entity?.hasData" [disabled]="(isLoading$ | async) || !(entity?.hasData && !entity?.url)"
(click)="onEntityAction($event, 'uploadPackage')" (click)="onEntityAction($event, 'uploadPackage')"
[fxShow]="!isEdit"> [fxShow]="!isEdit">
{{ 'ota-update.download' | translate }} {{ 'ota-update.download' | translate }}
@ -41,15 +41,23 @@
ngxClipboard ngxClipboard
(cbOnSuccess)="onPackageChecksumCopied()" (cbOnSuccess)="onPackageChecksumCopied()"
[cbContent]="entity?.checksum" [cbContent]="entity?.checksum"
[fxShow]="!isEdit"> [fxShow]="!isEdit && entity?.checksum">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon> <mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>ota-update.copy-checksum</span> <span translate>ota-update.copy-checksum</span>
</button> </button>
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onPackageDirectUrlCopied()"
[cbContent]="entity?.url"
[fxShow]="!isEdit && entity?.url">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>ota-update.copy-direct-url</span>
</button>
</div> </div>
</div> </div>
<div class="mat-padding" fxLayout="column" fxLayoutGap="8px"> <div class="mat-padding" fxLayout="column" fxLayoutGap="8px">
<form [formGroup]="entityForm"> <form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" fxLayout="column" fxLayoutGap="8px"> <fieldset [disabled]="(isLoading$ | async) || !isEdit" fxLayout="column">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column"> <div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayout.xs="column">
<mat-form-field class="mat-block" fxFlex="45"> <mat-form-field class="mat-block" fxFlex="45">
<mat-label translate>ota-update.title</mat-label> <mat-label translate>ota-update.title</mat-label>
@ -69,6 +77,7 @@
<tb-device-profile-autocomplete <tb-device-profile-autocomplete
formControlName="deviceProfileId" formControlName="deviceProfileId"
required required
[ngStyle]="{'padding-bottom': isAdd ? '16px': 0}"
[hint]="'ota-update.chose-compatible-device-profile'" [hint]="'ota-update.chose-compatible-device-profile'"
[editProfileEnabled]="false" [editProfileEnabled]="false"
[addNewProfile]="false" [addNewProfile]="false"
@ -82,8 +91,27 @@
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<div class="mat-caption" translate *ngIf="isAdd">ota-update.warning-after-save-no-edit</div> <section *ngIf="isAdd">
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayoutGap.sm="8px" fxLayout.xs="column" fxLayout.md="column"> <div class="mat-caption" style="margin: -8px 0 8px;" translate>ota-update.warning-after-save-no-edit</div>
<mat-radio-group formControlName="resource" fxLayoutGap="16px">
<mat-radio-button value="file">Upload binary file</mat-radio-button>
<mat-radio-button value="url">Use external URL</mat-radio-button>
</mat-radio-group>
</section>
<section *ngIf="entityForm.get('resource').value === 'file'">
<section *ngIf="isAdd">
<tb-file-input
formControlName="file"
workFromFileObj="true"
[required]="entityForm.get('resource').value === 'file'"
dropLabel="{{'ota-update.drop-file' | translate}}">
</tb-file-input>
<mat-checkbox formControlName="generateChecksum" style="margin-top: 16px">
{{ 'ota-update.auto-generate-checksum' | translate }}
</mat-checkbox>
</section>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayoutGap.sm="8px"
fxLayout.xs="column" fxLayout.md="column" *ngIf="!(isAdd && this.entityForm.get('generateChecksum').value)">
<mat-form-field class="mat-block" fxFlex="33"> <mat-form-field class="mat-block" fxFlex="33">
<mat-label translate>ota-update.checksum-algorithm</mat-label> <mat-label translate>ota-update.checksum-algorithm</mat-label>
<mat-select formControlName="checksumAlgorithm"> <mat-select formControlName="checksumAlgorithm">
@ -94,33 +122,38 @@
</mat-form-field> </mat-form-field>
<mat-form-field class="mat-block" fxFlex> <mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.checksum</mat-label> <mat-label translate>ota-update.checksum</mat-label>
<input matInput formControlName="checksum" type="text" [readonly]="!isAdd"> <input matInput formControlName="checksum" type="text">
<mat-hint *ngIf="isAdd" translate>ota-update.checksum-hint</mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<section *ngIf="isAdd">
<tb-file-input
formControlName="file"
workFromFileObj="true"
required
dropLabel="{{'ota-update.drop-file' | translate}}">
</tb-file-input>
</section>
<section *ngIf="!isAdd"> <section *ngIf="!isAdd">
<div fxLayout="row" fxLayoutGap.gt-md="8px" fxLayoutGap.sm="8px" fxLayout.xs="column" fxLayout.md="column"> <div fxLayout="row" fxLayoutGap.gt-md="8px" fxLayoutGap.sm="8px" fxLayout.xs="column" fxLayout.md="column">
<mat-form-field class="mat-block" fxFlex="33"> <mat-form-field class="mat-block" fxFlex="33">
<mat-label translate>ota-update.file-name</mat-label> <mat-label translate>ota-update.file-name</mat-label>
<input matInput formControlName="fileName" type="text" readonly> <input matInput formControlName="fileName" type="text">
</mat-form-field> </mat-form-field>
<mat-form-field class="mat-block" fxFlex> <mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.file-size-bytes</mat-label> <mat-label translate>ota-update.file-size-bytes</mat-label>
<input matInput formControlName="dataSize" type="text" readonly> <input matInput formControlName="dataSize" type="text">
</mat-form-field> </mat-form-field>
<mat-form-field class="mat-block" fxFlex> <mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.content-type</mat-label> <mat-label translate>ota-update.content-type</mat-label>
<input matInput formControlName="contentType" type="text" readonly> <input matInput formControlName="contentType" type="text">
</mat-form-field> </mat-form-field>
</div> </div>
</section> </section>
</section>
<section *ngIf="entityForm.get('resource').value === 'url'" style="margin-top: 8px">
<mat-form-field class="mat-block">
<mat-label translate>ota-update.direct-url</mat-label>
<input matInput formControlName="url"
type="text"
[required]="entityForm.get('resource').value === 'url'">
<mat-error *ngIf="entityForm.get('url').hasError('required')" translate>
ota-update.direct-url-required
</mat-error>
</mat-form-field>
</section>
<div formGroupName="additionalInfo"> <div formGroupName="additionalInfo">
<mat-form-field class="mat-block"> <mat-form-field class="mat-block">
<mat-label translate>ota-update.description</mat-label> <mat-label translate>ota-update.description</mat-label>

View File

@ -30,6 +30,8 @@ import {
OtaUpdateTypeTranslationMap OtaUpdateTypeTranslationMap
} from '@shared/models/ota-package.models'; } from '@shared/models/ota-package.models';
import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionNotificationShow } from '@core/notification/notification.actions';
import { filter, takeUntil } from 'rxjs/operators';
import { isNotEmptyStr } from '@core/utils';
@Component({ @Component({
selector: 'tb-ota-update', selector: 'tb-ota-update',
@ -52,6 +54,26 @@ export class OtaUpdateComponent extends EntityComponent<OtaPackage> implements O
super(store, fb, entityValue, entitiesTableConfigValue); 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() { ngOnDestroy() {
super.ngOnDestroy(); super.ngOnDestroy();
this.destroy$.next(); this.destroy$.next();
@ -74,6 +96,8 @@ export class OtaUpdateComponent extends EntityComponent<OtaPackage> implements O
deviceProfileId: [entity ? entity.deviceProfileId : null, Validators.required], deviceProfileId: [entity ? entity.deviceProfileId : null, Validators.required],
checksumAlgorithm: [entity && entity.checksumAlgorithm ? entity.checksumAlgorithm : ChecksumAlgorithm.SHA256], checksumAlgorithm: [entity && entity.checksumAlgorithm ? entity.checksumAlgorithm : ChecksumAlgorithm.SHA256],
checksum: [entity ? entity.checksum : '', Validators.maxLength(1020)], checksum: [entity ? entity.checksum : '', Validators.maxLength(1020)],
url: [entity ? entity.url : ''],
resource: ['file'],
additionalInfo: this.fb.group( additionalInfo: this.fb.group(
{ {
description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''],
@ -82,6 +106,7 @@ export class OtaUpdateComponent extends EntityComponent<OtaPackage> implements O
}); });
if (this.isAdd) { if (this.isAdd) {
form.addControl('file', this.fb.control(null, Validators.required)); form.addControl('file', this.fb.control(null, Validators.required));
form.addControl('generateChecksum', this.fb.control(true));
} else { } else {
form.addControl('fileName', this.fb.control(null)); form.addControl('fileName', this.fb.control(null));
form.addControl('dataSize', this.fb.control(null)); form.addControl('dataSize', this.fb.control(null));
@ -101,6 +126,8 @@ export class OtaUpdateComponent extends EntityComponent<OtaPackage> implements O
fileName: entity.fileName, fileName: entity.fileName,
dataSize: entity.dataSize, dataSize: entity.dataSize,
contentType: entity.contentType, contentType: entity.contentType,
url: entity.url,
resource: isNotEmptyStr(entity.url) ? 'url' : 'file',
additionalInfo: { additionalInfo: {
description: entity.additionalInfo ? entity.additionalInfo.description : '' description: entity.additionalInfo ? entity.additionalInfo.description : ''
} }
@ -108,8 +135,6 @@ export class OtaUpdateComponent extends EntityComponent<OtaPackage> implements O
if (!this.isAdd && this.entityForm.enabled) { if (!this.isAdd && this.entityForm.enabled) {
this.entityForm.disable({emitEvent: false}); this.entityForm.disable({emitEvent: false});
this.entityForm.get('additionalInfo').enable({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<OtaPackage> implements O
horizontalPosition: 'right' 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);
}
} }

View File

@ -0,0 +1,26 @@
<!--
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.
-->
<button mat-icon-button
[disabled]="disabled"
[matTooltip]="matTooltipText"
[matTooltipPosition]="matTooltipPosition"
(click)="copy($event)">
<mat-icon [svgIcon]="mdiIconSymbol" [ngStyle]="style" [ngClass]="{'copied': copied}">
{{ iconSymbol }}
</mat-icon>
</button>

View File

@ -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;
}
}
}

View File

@ -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<string>();
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;
}
}

View File

@ -216,7 +216,7 @@ export class OtaPackageAutocompleteComponent implements ControlValueAccessor, On
direction: Direction.ASC direction: Direction.ASC
}); });
return this.otaPackageService.getOtaPackagesInfoByDeviceProfileId(pageLink, this.deviceProfileId, this.type, 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) map((data) => data && data.data.length ? data.data : null)
); );
} }

View File

@ -87,6 +87,7 @@ export interface OtaPackageInfo extends BaseData<OtaPackageId> {
title?: string; title?: string;
version?: string; version?: string;
hasData?: boolean; hasData?: boolean;
url?: string;
fileName: string; fileName: string;
checksum?: string; checksum?: string;
checksumAlgorithm?: ChecksumAlgorithm; checksumAlgorithm?: ChecksumAlgorithm;

View File

@ -143,6 +143,7 @@ import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe';
import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component';
import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component'; import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component';
import { MAT_DATE_LOCALE } from '@angular/material/core'; import { MAT_DATE_LOCALE } from '@angular/material/core';
import { CopyButtonComponent } from '@shared/components/button/copy-button.component';
@NgModule({ @NgModule({
providers: [ providers: [
@ -240,7 +241,8 @@ import { MAT_DATE_LOCALE } from '@angular/material/core';
EntityGatewaySelectComponent, EntityGatewaySelectComponent,
ContactComponent, ContactComponent,
OtaPackageAutocompleteComponent, OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent WidgetsBundleSearchComponent,
CopyButtonComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -412,7 +414,8 @@ import { MAT_DATE_LOCALE } from '@angular/material/core';
EntityGatewaySelectComponent, EntityGatewaySelectComponent,
ContactComponent, ContactComponent,
OtaPackageAutocompleteComponent, OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent WidgetsBundleSearchComponent,
CopyButtonComponent
] ]
}) })
export class SharedModule { } export class SharedModule { }

View File

@ -575,7 +575,8 @@
"enter-password": "Enter password", "enter-password": "Enter password",
"enter-search": "Enter search", "enter-search": "Enter search",
"created-time": "Created time", "created-time": "Created time",
"loading": "Loading..." "loading": "Loading...",
"proceed": "Proceed"
}, },
"content-type": { "content-type": {
"json": "Json", "json": "Json",
@ -2157,21 +2158,30 @@
"assign-firmware-required": "Assigned firmware is required", "assign-firmware-required": "Assigned firmware is required",
"assign-software": "Assigned software", "assign-software": "Assigned software",
"assign-software-required": "Assigned software is required", "assign-software-required": "Assigned software is required",
"auto-generate-checksum": "Auto-generate checksum",
"checksum": "Checksum", "checksum": "Checksum",
"checksum-hint": "If checksum is empty, it will be generated automatically",
"checksum-algorithm": "Checksum algorithm", "checksum-algorithm": "Checksum algorithm",
"checksum-copied-message": "Package checksum has been copied to clipboard", "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-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", "chose-software-distributed-device": "Choose software that will be distributed to the devices",
"content-type": "Content type", "content-type": "Content type",
"copy-checksum": "Copy checksum", "copy-checksum": "Copy checksum",
"copy-direct-url": "Copy direct URL",
"copyId": "Copy package Id", "copyId": "Copy package Id",
"copied": "Copied!",
"delete": "Delete package", "delete": "Delete package",
"delete-ota-update-text": "Be careful, after the confirmation the OTA update will become unrecoverable.", "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-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-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} }?", "delete-ota-updates-title": "Are you sure you want to delete { count, plural, 1 {1 OTA update} other {# OTA updates} }?",
"description": "Description", "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", "download": "Download package",
"drop-file": "Drop a package file or click to select a file to upload.", "drop-file": "Drop a package file or click to select a file to upload.",
"file-name": "File name", "file-name": "File name",