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)) {
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())) {

View File

@ -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())) {

View File

@ -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<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> {
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,
hasData = true, config?: RequestConfig): Observable<PageData<OtaPackageInfo>> {
config?: RequestConfig): Observable<PageData<OtaPackageInfo>> {
const url = `/api/otaPackages/${deviceProfileId}/${type}${pageLink.toQuery()}`;
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));
}
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"
[matTooltip]="cellTooltip(entity, column, row)"
matTooltipPosition="above"
[innerHTML]="cellContent(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>
</ng-container>
<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 { 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<BaseData<HasId>>;
cellActionType = CellActionDescriptorType;
isDetailsOpen = false;
detailsPanelOpened = new EventEmitter<boolean>();

View File

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

View File

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

View File

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

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 EntityIdsVoidFunction<T extends BaseData<HasId>> = (ids: HasUUID[]) => void;
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 EntityIdOneWayOperation = (id: HasUUID) => Observable<any>;
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 HeaderCellStyleFunction<T extends BaseData<HasId>> = (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>> {
name: string;
@ -56,7 +59,8 @@ export interface CellActionDescriptor<T extends BaseData<HasId>> {
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<T extends BaseData<HasId>> {
@ -95,7 +99,8 @@ export class EntityTableColumn<T extends BaseData<HasId>> extends BaseEntityTabl
public sortable: boolean = true,
public headerCellStyleFunction: HeaderCellStyleFunction<T> = () => ({}),
public cellTooltipFunction: CellTooltipFunction<T> = () => undefined,
public isNumberColumn: boolean = false) {
public isNumberColumn: boolean = false,
public actionCell: CellActionDescriptor<T> = null) {
super('content', key, title, width, sortable);
}
}
@ -173,7 +178,7 @@ export class EntityTableConfig<T extends BaseData<HasId>, P extends PageLink = P
deleteEntitiesTitle: EntityCountStringFunction = () => '';
deleteEntitiesContent: EntityCountStringFunction = () => '';
loadEntity: EntityByIdOperation<T | L> = () => of();
saveEntity: EntityTwoWayOperation<T> = (entity) => of(entity);
saveEntity: EntityTwoWayOperation<T> = (entity, originalEntity) => of(entity);
deleteEntity: EntityIdOneWayOperation = () => of();
entitiesFetchFunction: EntitiesFetchFunction<L, P> = () => of(emptyPageData<L>());
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.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;

View File

@ -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<EntityTableConfig<OtaPackage, PageLink, OtaPackageInfo>> {
@ -44,6 +47,7 @@ export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<Ot
constructor(private translate: TranslateService,
private datePipe: DatePipe,
private store: Store<AppState>,
private otaPackageService: OtaPackageService,
private fileSize: FileSizePipe) {
this.config.entityType = EntityType.OTA_PACKAGE;
@ -55,25 +59,50 @@ export class OtaUpdateTableConfigResolve implements Resolve<EntityTableConfig<Ot
this.config.columns.push(
new DateEntityTableColumn<OtaPackageInfo>('createdTime', 'common.created-time', this.datePipe, '150px'),
new EntityTableColumn<OtaPackageInfo>('title', 'ota-update.title', '25%'),
new EntityTableColumn<OtaPackageInfo>('version', 'ota-update.version', '25%'),
new EntityTableColumn<OtaPackageInfo>('type', 'ota-update.package-type', '25%', entity => {
new EntityTableColumn<OtaPackageInfo>('title', 'ota-update.title', '20%'),
new EntityTableColumn<OtaPackageInfo>('version', 'ota-update.version', '20%'),
new EntityTableColumn<OtaPackageInfo>('type', 'ota-update.package-type', '20%', entity => {
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 => {
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<OtaPackageInfo>('fileName', 'ota-update.file-name', '20%'),
new EntityTableColumn<OtaPackageInfo>('dataSize', 'ota-update.file-size', '70px', entity => {
return this.fileSize.transform(entity.dataSize || 0);
return entity.dataSize ? this.fileSize.transform(entity.dataSize) : '';
}),
new EntityTableColumn<OtaPackageInfo>('checksum', 'ota-update.checksum', '540px', entity => {
return `${ChecksumAlgorithmTranslationMap.get(entity.checksumAlgorithm)}: ${entity.checksum}`;
}, () => ({}), false)
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(
{
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<EntityTableConfig<Ot
if ($event) {
$event.stopPropagation();
}
this.otaPackageService.downloadOtaPackage(otaPackageInfo.id.id).subscribe();
if (otaPackageInfo.url) {
window.open(otaPackageInfo.url, '_blank');
} else {
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 {

View File

@ -17,7 +17,7 @@
-->
<div class="tb-details-buttons" fxLayout.xs="column">
<button mat-raised-button color="primary" fxFlex.xs
[disabled]="(isLoading$ | async) || !entity?.hasData"
[disabled]="(isLoading$ | async) || !(entity?.hasData && !entity?.url)"
(click)="onEntityAction($event, 'uploadPackage')"
[fxShow]="!isEdit">
{{ 'ota-update.download' | translate }}
@ -41,15 +41,23 @@
ngxClipboard
(cbOnSuccess)="onPackageChecksumCopied()"
[cbContent]="entity?.checksum"
[fxShow]="!isEdit">
[fxShow]="!isEdit && entity?.checksum">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>ota-update.copy-checksum</span>
</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 class="mat-padding" fxLayout="column" fxLayoutGap="8px">
<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">
<mat-form-field class="mat-block" fxFlex="45">
<mat-label translate>ota-update.title</mat-label>
@ -69,6 +77,7 @@
<tb-device-profile-autocomplete
formControlName="deviceProfileId"
required
[ngStyle]="{'padding-bottom': isAdd ? '16px': 0}"
[hint]="'ota-update.chose-compatible-device-profile'"
[editProfileEnabled]="false"
[addNewProfile]="false"
@ -82,44 +91,68 @@
</mat-option>
</mat-select>
</mat-form-field>
<div class="mat-caption" translate *ngIf="isAdd">ota-update.warning-after-save-no-edit</div>
<div fxLayout="row" fxLayoutGap.gt-xs="8px" fxLayoutGap.sm="8px" fxLayout.xs="column" fxLayout.md="column">
<mat-form-field class="mat-block" fxFlex="33">
<mat-label translate>ota-update.checksum-algorithm</mat-label>
<mat-select formControlName="checksumAlgorithm">
<mat-option *ngFor="let checksumAlgorithm of checksumAlgorithms" [value]="checksumAlgorithm">
{{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.checksum</mat-label>
<input matInput formControlName="checksum" type="text" [readonly]="!isAdd">
</mat-form-field>
</div>
<section *ngIf="isAdd">
<tb-file-input
formControlName="file"
workFromFileObj="true"
required
dropLabel="{{'ota-update.drop-file' | translate}}">
</tb-file-input>
<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="!isAdd">
<div fxLayout="row" fxLayoutGap.gt-md="8px" fxLayoutGap.sm="8px" fxLayout.xs="column" fxLayout.md="column">
<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-label translate>ota-update.file-name</mat-label>
<input matInput formControlName="fileName" type="text" readonly>
<mat-label translate>ota-update.checksum-algorithm</mat-label>
<mat-select formControlName="checksumAlgorithm">
<mat-option *ngFor="let checksumAlgorithm of checksumAlgorithms" [value]="checksumAlgorithm">
{{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.file-size-bytes</mat-label>
<input matInput formControlName="dataSize" type="text" readonly>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.content-type</mat-label>
<input matInput formControlName="contentType" type="text" readonly>
<mat-label translate>ota-update.checksum</mat-label>
<input matInput formControlName="checksum" type="text">
<mat-hint *ngIf="isAdd" translate>ota-update.checksum-hint</mat-hint>
</mat-form-field>
</div>
<section *ngIf="!isAdd">
<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-label translate>ota-update.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.file-size-bytes</mat-label>
<input matInput formControlName="dataSize" type="text">
</mat-form-field>
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>ota-update.content-type</mat-label>
<input matInput formControlName="contentType" type="text">
</mat-form-field>
</div>
</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">
<mat-form-field class="mat-block">

View File

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

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

View File

@ -87,6 +87,7 @@ export interface OtaPackageInfo extends BaseData<OtaPackageId> {
title?: string;
version?: string;
hasData?: boolean;
url?: string;
fileName: string;
checksum?: string;
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 { 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 { }

View File

@ -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",