diff --git a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java index e2b6b297ab..4e4b2a86e2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -38,6 +38,7 @@ import java.util.UUID; import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.iot_hub.InstallItemVersionResult; +import org.thingsboard.server.service.iot_hub.UpdateItemVersionResult; import org.thingsboard.server.service.iot_hub.IotHubService; @Hidden @@ -58,6 +59,14 @@ public class IotHubController extends BaseController { return iotHubService.installItemVersion(getCurrentUser(), versionId); } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/installedItems/{itemId}/update/{versionId}") + @ResponseBody + public UpdateItemVersionResult updateItemVersion(@PathVariable UUID itemId, + @PathVariable String versionId) throws ThingsboardException { + return iotHubService.updateItemVersion(getCurrentUser(), itemId, versionId); + } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping("/installedItems") @ResponseBody diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java index bef8cfdd10..6a80412a99 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.iot_hub.WidgetInstalledItemDescriptor; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainData; import org.thingsboard.server.common.data.rule.RuleChainImportResult; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.dashboard.DashboardService; @@ -121,7 +122,12 @@ public class DefaultIotHubService implements IotHubService { } private WidgetInstalledItemDescriptor installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { - WidgetTypeDetails widgetTypeDetails = JacksonUtil.fromBytes(fileData, WidgetTypeDetails.class); + WidgetTypeDetails widgetTypeDetails; + try { + widgetTypeDetails = JacksonUtil.fromString(new String(fileData), WidgetTypeDetails.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse widget data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } widgetTypeDetails.setId(null); widgetTypeDetails.setTenantId(tenantId); WidgetTypeDetails saved = tbWidgetTypeService.save(widgetTypeDetails, true, user); @@ -132,7 +138,12 @@ public class DefaultIotHubService implements IotHubService { } private DashboardInstalledItemDescriptor installDashboard(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { - Dashboard dashboard = JacksonUtil.fromBytes(fileData, Dashboard.class); + Dashboard dashboard; + try { + dashboard = JacksonUtil.fromString(new String(fileData), Dashboard.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse dashboard data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } dashboard.setId(null); dashboard.setTenantId(tenantId); Dashboard saved = tbDashboardService.save(dashboard, user); @@ -143,7 +154,12 @@ public class DefaultIotHubService implements IotHubService { } private CalculatedFieldInstalledItemDescriptor installCalculatedField(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { - CalculatedField calculatedField = JacksonUtil.fromBytes(fileData, CalculatedField.class); + CalculatedField calculatedField; + try { + calculatedField = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse calculated field data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } calculatedField.setId(null); calculatedField.setTenantId(tenantId); CalculatedField saved = tbCalculatedFieldService.save(calculatedField, user); @@ -153,19 +169,43 @@ public class DefaultIotHubService implements IotHubService { return descriptor; } - private RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) { - RuleChainData ruleChainData = JacksonUtil.fromBytes(fileData, RuleChainData.class); - List results = ruleChainService.importTenantRuleChains(tenantId, ruleChainData, false, tbRuleChainService::updateRuleNodeConfiguration); - log.debug("[{}] Rule chain(s) installed", tenantId); - RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor(); - if (!results.isEmpty()) { - descriptor.setRuleChainId(results.get(0).getRuleChainId()); + private RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) throws Exception { + JsonNode json = JacksonUtil.toJsonNode(new String(fileData)); + + RuleChain ruleChain; + try { + ruleChain = JacksonUtil.fromString(json.get("ruleChain").toString(), RuleChain.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse rule chain: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } + + RuleChainMetaData metadata; + try { + metadata = JacksonUtil.fromString(json.get("metadata").toString(), RuleChainMetaData.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse rule chain metadata: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); } + + ruleChain.setId(null); + ruleChain.setTenantId(tenantId); + RuleChain savedRuleChain = ruleChainService.saveRuleChain(ruleChain); + + metadata.setRuleChainId(savedRuleChain.getId()); + ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration); + + log.debug("[{}] Rule chain installed: {}", tenantId, savedRuleChain.getName()); + RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor(); + descriptor.setRuleChainId(savedRuleChain.getId()); return descriptor; } private DeviceInstalledItemDescriptor installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { - DeviceProfile deviceProfile = JacksonUtil.fromBytes(fileData, DeviceProfile.class); + DeviceProfile deviceProfile; + try { + deviceProfile = JacksonUtil.fromString(new String(fileData), DeviceProfile.class, true); + } catch (Exception e) { + throw new Exception("Failed to parse device profile data: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()), e); + } deviceProfile.setId(null); deviceProfile.setTenantId(tenantId); tbDeviceProfileService.save(deviceProfile, user); @@ -173,6 +213,37 @@ public class DefaultIotHubService implements IotHubService { return new DeviceInstalledItemDescriptor(); } + @Override + public UpdateItemVersionResult updateItemVersion(SecurityUser user, UUID itemId, String versionId) { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Updating IoT Hub item {} to version: {}", tenantId, itemId, versionId); + + try { + IotHubInstalledItem installedItem = iotHubInstalledItemService.findByTenantIdAndItemId(tenantId, itemId) + .orElseThrow(() -> new IllegalArgumentException("Installed item not found")); + + JsonNode installedVersionInfo = iotHubRestClient.getVersionInfo(installedItem.getItemVersionId().toString()); + String installedChecksum = installedVersionInfo.has("checksum") ? installedVersionInfo.get("checksum").asText() : null; + log.info("[{}] Installed version info: name={}, version={}, checksum={}", tenantId, installedItem.getItemName(), installedItem.getVersion(), installedChecksum); + + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + String itemName = versionInfo.get("name").asText(); + String version = versionInfo.get("version").asText(); + + byte[] fileData = iotHubRestClient.getVersionFileData(versionId); + log.info("[{}] Fetched update file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0); + + // TODO: apply update per item type + + log.info("[{}] Successfully updated IoT Hub item {} to version: {}", tenantId, itemName, version); + + return UpdateItemVersionResult.success(installedItem.getDescriptor()); + } catch (Exception e) { + log.error("[{}] Failed to update IoT Hub item {} to version: {}", tenantId, itemId, versionId, e); + return UpdateItemVersionResult.error(e.getMessage()); + } + } + @Override public void deleteInstalledItem(SecurityUser user, UUID itemId) { TenantId tenantId = user.getTenantId(); diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java index f0cfc6c49d..e17ca5d4f2 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java @@ -23,5 +23,7 @@ public interface IotHubService { InstallItemVersionResult installItemVersion(SecurityUser user, String versionId); + UpdateItemVersionResult updateItemVersion(SecurityUser user, UUID itemId, String versionId); + void deleteInstalledItem(SecurityUser user, UUID itemId); } diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java new file mode 100644 index 0000000000..b056b29e6a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2026 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. + */ +package org.thingsboard.server.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateItemVersionResult { + + private boolean success; + private String errorMessage; + private IotHubInstalledItemDescriptor descriptor; + + public static UpdateItemVersionResult success(IotHubInstalledItemDescriptor descriptor) { + return new UpdateItemVersionResult(true, null, descriptor); + } + + public static UpdateItemVersionResult error(String errorMessage) { + return new UpdateItemVersionResult(false, errorMessage, null); + } + +} diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts index 5f50a27597..587e37a155 100644 --- a/ui-ngx/src/app/core/http/iot-hub-api.service.ts +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -22,7 +22,7 @@ import { PageData } from '@shared/models/page/page-data'; import { PageLink } from '@shared/models/page/page-link'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models'; -import { IotHubInstalledItem, IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubInstalledItem, IotHubInstalledItemInfo, InstallItemVersionResult, UpdateItemVersionResult, ItemUpdateInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params'; import { InterceptorConfig } from '@core/interceptors/interceptor-config'; import { AppState } from '@core/core.state'; @@ -112,14 +112,22 @@ export class IotHubApiService { ); } - public installItemVersion(versionId: string, config?: IotHubRequestConfig): Observable { - return this.http.post( + public installItemVersion(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.post( `/api/iot-hub/versions/${versionId}/install`, null, { params: this.buildParams(config) } ); } + public updateItemVersion(itemId: string, versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.post( + `/api/iot-hub/installedItems/${itemId}/update/${versionId}`, + null, + { params: this.buildParams(config) } + ); + } + public getInstalledItemByItemId(itemId: string, config?: IotHubRequestConfig): Observable { return this.http.get( `/api/iot-hub/installedItems/byItemId/${itemId}`, @@ -148,6 +156,14 @@ export class IotHubApiService { ); } + public checkForUpdates(infos: IotHubInstalledItemInfo[], config?: IotHubRequestConfig): Observable { + return this.http.post( + `${this.baseUrl}/api/versions/checkForUpdates`, + infos, + { params: this.buildParams(config) } + ); + } + public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable { return this.http.get( `${this.baseUrl}/api/creators/${creatorId}/profile`, diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html index 83cf44e087..5b350ec9ee 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html @@ -194,9 +194,11 @@ [showTypeChip]="false" [showCreator]="!creatorId" [installed]="isInstalled(item)" + [installedItemInfo]="installedItemsMap.get(item.itemId) || null" (cardClick)="openItemDetail($event)" (creatorClick)="navigateToCreator($event)" - (installClick)="installItem($event)"> + (installClick)="installItem($event)" + (updateClick)="updateItem($event)"> } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts index 004dac5fde..27f370856d 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts @@ -30,9 +30,10 @@ import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { MatDialog } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; +import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; interface SortOption { value: string; @@ -99,7 +100,8 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { private iotHubApiService: IotHubApiService, private dialog: MatDialog, private translate: TranslateService, - private router: Router + private router: Router, + private route: ActivatedRoute ) {} ngOnInit(): void { @@ -111,7 +113,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { this.loadItems(); }); this.updateCategories(); - this.loadInstalledItemInfos(); + this.initInstalledItemInfos(); this.loadItems(); } @@ -342,7 +344,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { } as IotHubItemDetailDialogData }); dialogRef.afterClosed().subscribe(result => { - if (result === 'installed') { + if (result === 'installed' || result === 'updated') { this.loadInstalledItemInfos(); } }); @@ -363,6 +365,29 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { }); } + updateItem(item: MpItemVersionView): void { + const installedInfo = this.installedItemsMap.get(item.itemId); + if (!installedInfo) { + return; + } + const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { + panelClass: ['tb-dialog'], + data: { + itemId: item.itemId, + itemName: item.name, + itemType: item.type, + version: item.version, + versionId: item.id, + iotHubApiService: this.iotHubApiService + } as IotHubUpdateDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'updated') { + this.loadInstalledItemInfos(); + } + }); + } + navigateToCreator(creatorId: string): void { this.router.navigate(['/iot-hub/creator', creatorId]); } @@ -371,6 +396,12 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { this.router.navigate(['/iot-hub/installed']); } + private initInstalledItemInfos(): void { + const infos: IotHubInstalledItemInfo[] = this.route.snapshot.data['installedItemInfos'] || []; + this.installedItemsMap.clear(); + infos.forEach(info => this.installedItemsMap.set(info.itemId, info)); + } + private loadInstalledItemInfos(): void { this.iotHubApiService.getInstalledItemInfos({ignoreLoading: true}).subscribe(infos => { this.installedItemsMap.clear(); diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts index 0fe5dd867b..4c7edea820 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts @@ -16,13 +16,14 @@ import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { Router } from '@angular/router'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { TranslateService } from '@ngx-translate/core'; +import { EntityType } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL } from '@core/utils'; export interface IotHubInstallDialogData { item: MpItemVersionView; @@ -35,36 +36,65 @@ export type InstallState = 'confirm' | 'installing' | 'success' | 'error'; selector: 'tb-iot-hub-install-dialog', standalone: false, template: ` -

{{ 'iot-hub.install-item-title' | translate }}

- -

{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}

-

{{ 'iot-hub.install-type' | translate:{ type: (typeTranslations.get(item.type) | translate) } }}

-

{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}

-
- - - @switch (state) { - @case ('confirm') { - - } - @case ('installing') { + @switch (state) { + @case ('confirm') { +

{{ 'iot-hub.install-item-title' | translate }}

+ +

{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}

+

{{ 'iot-hub.install-type' | translate:{ type: getTypeLabel() } }}

+

{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}

+
+ + + + + } + @case ('installing') { +

{{ 'iot-hub.install-item-title' | translate }}

+ +

{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}

+

{{ 'iot-hub.install-type' | translate:{ type: getTypeLabel() } }}

+

{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}

+
+ + - } - @case ('success') { - - } + + } + @case ('success') { +

+ check_circle + {{ 'iot-hub.install-success-title' | translate }} +

+ +

{{ 'iot-hub.install-success-message' | translate:{ name: item.name, version: item.version } }}

+
+ + + @if (entityDetailsUrl) { + + } + } -
+ @case ('error') { +

+ error + {{ 'iot-hub.install-error-title' | translate }} +

+ +

{{ 'iot-hub.install-error-message' | translate:{ name: item.name } }}

+
{{ errorMessage }}
+
+ + + + } + } `, styles: [` .tb-iot-hub-install-meta { @@ -76,23 +106,61 @@ export type InstallState = 'confirm' | 'installing' | 'success' | 'error'; display: inline-block; margin-right: 8px; } + .tb-iot-hub-result-icon { + vertical-align: middle; + margin-right: 8px; + } + .tb-iot-hub-success-icon { + color: #2e7d32; + } + .tb-iot-hub-error-icon { + color: #c62828; + } + .tb-iot-hub-error-details { + max-height: 200px; + overflow-y: auto; + padding: 12px; + background: #f5f5f5; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + color: rgba(0, 0, 0, 0.7); + white-space: pre-wrap; + word-break: break-word; + margin-top: 8px; + } `] }) export class TbIotHubInstallDialogComponent { + private static readonly ITEM_TYPE_TO_ENTITY_TYPE: Record = { + 'WIDGET': EntityType.WIDGET_TYPE, + 'DASHBOARD': EntityType.DASHBOARD, + 'CALCULATED_FIELD': EntityType.CALCULATED_FIELD, + 'RULE_CHAIN': EntityType.RULE_CHAIN, + 'DEVICE': EntityType.DEVICE_PROFILE + }; + item: MpItemVersionView; typeTranslations = itemTypeTranslations; state: InstallState = 'confirm'; + errorMessage = ''; + entityDetailsUrl: string | null = null; constructor( @Inject(MAT_DIALOG_DATA) public data: IotHubInstallDialogData, private dialogRef: MatDialogRef, - private store: Store, + private router: Router, private translate: TranslateService ) { this.item = data.item; } + getTypeLabel(): string { + const key = this.typeTranslations.get(this.item.type); + return key ? this.translate.instant(key) : ''; + } + install(): void { this.state = 'installing'; const versionId = this.item.id as string; @@ -100,33 +168,52 @@ export class TbIotHubInstallDialogComponent { next: (result) => { if (result.success) { this.state = 'success'; - this.store.dispatch(new ActionNotificationShow({ - message: this.translate.instant('iot-hub.install-success', { name: this.item.name }), - type: 'success', - duration: 3000 - })); - setTimeout(() => this.dialogRef.close('installed'), 1500); + this.entityDetailsUrl = this.resolveEntityDetailsUrl(result.descriptor); } else { - this.state = 'confirm'; - this.store.dispatch(new ActionNotificationShow({ - message: result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }), - type: 'error', - duration: 5000 - })); + this.state = 'error'; + this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }); } }, - error: () => { - this.state = 'confirm'; - this.store.dispatch(new ActionNotificationShow({ - message: this.translate.instant('iot-hub.install-error', { name: this.item.name }), - type: 'error', - duration: 5000 - })); + error: (err) => { + this.state = 'error'; + this.errorMessage = err?.error?.message || err?.message || this.translate.instant('iot-hub.install-error', { name: this.item.name }); } }); } + openEntityDetails(): void { + if (this.entityDetailsUrl) { + this.dialogRef.close('installed'); + this.router.navigateByUrl(this.entityDetailsUrl); + } + } + + close(): void { + this.dialogRef.close(this.state === 'success' ? 'installed' : false); + } + cancel(): void { this.dialogRef.close(false); } + + private resolveEntityDetailsUrl(descriptor: IotHubInstalledItemDescriptor): string | null { + if (!descriptor) { + return null; + } + const entityType = TbIotHubInstallDialogComponent.ITEM_TYPE_TO_ENTITY_TYPE[this.item.type]; + if (!entityType) { + return null; + } + let entityId: string | null = null; + switch (descriptor.type) { + case 'WIDGET': entityId = descriptor.widgetTypeId?.id; break; + case 'DASHBOARD': entityId = descriptor.dashboardId?.id; break; + case 'CALCULATED_FIELD': entityId = descriptor.calculatedFieldId?.id; break; + case 'RULE_CHAIN': entityId = descriptor.ruleChainId?.id; break; + } + if (!entityId) { + return null; + } + return getEntityDetailsPageURL(entityId, entityType) || null; + } } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-item-infos.resolver.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-item-infos.resolver.ts new file mode 100644 index 0000000000..f54eaebf0a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-item-infos.resolver.ts @@ -0,0 +1,25 @@ +/// +/// Copyright © 2016-2026 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 { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; + +export const iotHubInstalledItemInfosResolver: ResolveFn = () => { + const iotHubApiService = inject(IotHubApiService); + return iotHubApiService.getInstalledItemInfos({ ignoreLoading: true }); +}; diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html index b76b8301a8..24f2e9f9e0 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html @@ -22,6 +22,18 @@

{{ 'iot-hub.installed-items' | translate }}

+ @if (!updatesChecked) { + + }
@@ -64,7 +76,34 @@ {{ 'iot-hub.installed-date' | translate }} - {{ item.createdTime | date:'medium' }} + {{ item.createdTime | date:'mediumDate' }} + + + + {{ updatesChecked ? ('iot-hub.updates' | translate) : '' }} + + @if (updatesChecked) { + @if (getUpdateInfo(item); as updateInfo) { + @if (updateInfo.hasUpdate) { + + + } @else { + {{ 'iot-hub.up-to-date' | translate }} + } + } @else { + {{ 'iot-hub.up-to-date' | translate }} + } + } + diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss index b8d0596781..7e8f080553 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss @@ -57,6 +57,9 @@ } .tb-iot-hub-installed-items-header { + display: flex; + align-items: center; + justify-content: space-between; margin-bottom: 24px; h2 { @@ -67,6 +70,11 @@ } } +.tb-iot-hub-inline-spinner { + display: inline-block; + margin-right: 8px; +} + .tb-iot-hub-installed-search-row { display: flex; gap: 12px; @@ -174,7 +182,33 @@ width: 100%; } + .mat-column-itemName { + width: 100%; + } + + .mat-column-itemType, + .mat-column-version { + width: 100px; + min-width: 100px; + max-width: 100px; + } + + .mat-column-createdTime { + width: 100px; + min-width: 100px; + max-width: 100px; + } + + .mat-column-updates { + width: 200px; + min-width: 200px; + max-width: 200px; + } + .mat-column-actions { + width: 144px; + min-width: 144px; + max-width: 144px; text-align: right; white-space: nowrap; } @@ -194,6 +228,23 @@ &.tb-type-device { background: #eceff1; color: #455a64; } } +.tb-iot-hub-up-to-date { + color: #2e7d32; + font-size: 13px; + font-weight: 500; +} + +.tb-iot-hub-update-button { + font-size: 12px; + line-height: 28px; + height: 28px; + padding: 0 12px; + + & + .tb-iot-hub-update-button { + margin-left: 8px; + } +} + .tb-iot-hub-installed-empty { padding: 48px 24px; text-align: center; diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts index 0c2902856f..4dc173637e 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts @@ -15,7 +15,7 @@ /// import { Component, OnInit, AfterViewInit, ViewChild } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { MatSort } from '@angular/material/sort'; import { MatPaginator } from '@angular/material/paginator'; @@ -29,11 +29,12 @@ import { Subject } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; -import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { IotHubInstalledItem, IotHubInstalledItemInfo, ItemUpdateInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { EntityType } from '@shared/models/entity-type.models'; import { getEntityDetailsPageURL } from '@core/utils'; import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component'; +import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; @Component({ selector: 'tb-iot-hub-installed-items', @@ -43,7 +44,7 @@ import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '. }) export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { - displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'actions']; + displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'updates', 'actions']; dataSource: IotHubInstalledItem[] = []; totalElements = 0; pageSize = 10; @@ -51,6 +52,11 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { isLoading = false; textSearch = ''; + installedItemInfos: IotHubInstalledItemInfo[] = []; + updateInfoMap = new Map(); + updatesChecked = false; + isCheckingUpdates = false; + private searchSubject = new Subject(); @ViewChild(MatSort, {static: true}) sort: MatSort; @@ -70,10 +76,12 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { private translate: TranslateService, private store: Store, private router: Router, - private dialog: MatDialog + private dialog: MatDialog, + private route: ActivatedRoute ) {} ngOnInit(): void { + this.installedItemInfos = this.route.snapshot.data['installedItemInfos'] || []; this.searchSubject.pipe( debounceTime(300) ).subscribe(() => { @@ -187,6 +195,58 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { } } + checkForUpdates(): void { + this.isCheckingUpdates = true; + this.iotHubApiService.checkForUpdates(this.installedItemInfos, { ignoreLoading: true }).subscribe({ + next: (updates) => { + this.updateInfoMap.clear(); + updates.forEach(info => this.updateInfoMap.set(info.itemId, info)); + this.updatesChecked = true; + this.isCheckingUpdates = false; + }, + error: () => { + this.isCheckingUpdates = false; + } + }); + } + + viewUpdateDetails(updateInfo: ItemUpdateInfo, installedItem: IotHubInstalledItem): void { + this.iotHubApiService.getVersionInfo(updateInfo.latestItemVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + item: versionView, + iotHubApiService: this.iotHubApiService, + installedDescriptor: installedItem.descriptor, + installedItemInfo: { itemId: installedItem.itemId, itemVersionId: installedItem.itemVersionId } + } as IotHubItemDetailDialogData + }); + }); + } + + updateItem(item: IotHubInstalledItem, updateInfo: ItemUpdateInfo): void { + const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { + panelClass: ['tb-dialog'], + data: { + itemId: item.itemId, + itemName: item.itemName, + itemType: item.itemType as ItemType, + version: updateInfo.latestVersion, + versionId: updateInfo.latestItemVersionId, + iotHubApiService: this.iotHubApiService + } as IotHubUpdateDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'updated') { + this.loadData(); + } + }); + } + + getUpdateInfo(item: IotHubInstalledItem): ItemUpdateInfo | undefined { + return this.updateInfoMap.get(item.itemId); + } + private loadData(): void { this.isLoading = true; const sortOrder: SortOrder = { diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html index 3eb8620633..510d626afd 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html @@ -101,7 +101,11 @@ {{ item.totalInstallCount | shortNumber }} @if (installed) { - {{ 'iot-hub.installed' | translate }} + @if (hasUpdate()) { + + } @else { + {{ 'iot-hub.installed' | translate }} + } } @else { } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss index 20f1d873b9..8eed3e6980 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss @@ -358,4 +358,22 @@ font-weight: 600; white-space: nowrap; } + + .tb-iot-hub-card-update-btn { + padding: 5px 14px; + border-radius: 6px; + border: 1px solid #e65100; + background: transparent; + color: #e65100; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + + &:hover { + background: #e65100; + color: #fff; + } + } } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts index 709559e534..41a664b49c 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts @@ -19,6 +19,7 @@ import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTransl import { ItemType, itemTypeTranslations, getCategoriesForType, useCaseTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { TranslateService } from '@ngx-translate/core'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; @Component({ selector: 'tb-iot-hub-item-card', @@ -36,9 +37,11 @@ export class TbIotHubItemCardComponent { @Input() showCreator = true; @Input() showTypeChip = true; @Input() installed = false; + @Input() installedItemInfo: IotHubInstalledItemInfo | null = null; @Output() cardClick = new EventEmitter(); @Output() creatorClick = new EventEmitter(); @Output() installClick = new EventEmitter(); + @Output() updateClick = new EventEmitter(); typeTranslations = itemTypeTranslations; @@ -187,11 +190,21 @@ export class TbIotHubItemCardComponent { this.cardClick.emit(this.item); } + hasUpdate(): boolean { + return this.installed && this.installedItemInfo != null + && this.installedItemInfo.itemVersionId !== this.item.id; + } + onInstallClick(event: MouseEvent): void { event.stopPropagation(); this.installClick.emit(this.item); } + onUpdateClick(event: MouseEvent): void { + event.stopPropagation(); + this.updateClick.emit(this.item); + } + onCreatorClick(event: MouseEvent): void { event.stopPropagation(); this.creatorClick.emit(this.item.creatorId); diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html index f4746d4b63..c4400af2cc 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html @@ -174,15 +174,22 @@