Browse Source

Add IoT Hub update flow and improve install error handling

- Add update item version endpoint and service method (backend)
- Add update dialog component with confirm/updating/success/error states
- Add check for updates feature in installed items table
- Show update button on browse cards and detail dialog when update available
- Add route resolver for installed item infos shared across pages
- Improve deserialization: use ignoreUnknownFields, root cause error messages
- Refactor rule chain install to parse ruleChain and metadata separately
pull/15347/head
Igor Kulikov 7 months ago
parent
commit
f49e1b6b4f
  1. 9
      application/src/main/java/org/thingsboard/server/controller/IotHubController.java
  2. 93
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  3. 2
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
  4. 40
      application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java
  5. 22
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  6. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html
  7. 39
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts
  8. 185
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts
  9. 25
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-item-infos.resolver.ts
  10. 41
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html
  11. 51
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss
  12. 68
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts
  13. 6
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html
  14. 18
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss
  15. 13
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts
  16. 25
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html
  17. 26
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss
  18. 27
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts
  19. 7
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts
  20. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts
  21. 19
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts
  22. 18
      ui-ngx/src/assets/locale/locale.constant-en_US.json

9
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.dao.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.iot_hub.InstallItemVersionResult; import org.thingsboard.server.service.iot_hub.InstallItemVersionResult;
import org.thingsboard.server.service.iot_hub.UpdateItemVersionResult;
import org.thingsboard.server.service.iot_hub.IotHubService; import org.thingsboard.server.service.iot_hub.IotHubService;
@Hidden @Hidden
@ -58,6 +59,14 @@ public class IotHubController extends BaseController {
return iotHubService.installItemVersion(getCurrentUser(), versionId); 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')") @PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping("/installedItems") @GetMapping("/installedItems")
@ResponseBody @ResponseBody

93
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.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainData; import org.thingsboard.server.common.data.rule.RuleChainData;
import org.thingsboard.server.common.data.rule.RuleChainImportResult; 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.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.dashboard.DashboardService; 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 { 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.setId(null);
widgetTypeDetails.setTenantId(tenantId); widgetTypeDetails.setTenantId(tenantId);
WidgetTypeDetails saved = tbWidgetTypeService.save(widgetTypeDetails, true, user); 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 { 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.setId(null);
dashboard.setTenantId(tenantId); dashboard.setTenantId(tenantId);
Dashboard saved = tbDashboardService.save(dashboard, user); 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 { 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.setId(null);
calculatedField.setTenantId(tenantId); calculatedField.setTenantId(tenantId);
CalculatedField saved = tbCalculatedFieldService.save(calculatedField, user); CalculatedField saved = tbCalculatedFieldService.save(calculatedField, user);
@ -153,19 +169,43 @@ public class DefaultIotHubService implements IotHubService {
return descriptor; return descriptor;
} }
private RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) { private RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) throws Exception {
RuleChainData ruleChainData = JacksonUtil.fromBytes(fileData, RuleChainData.class); JsonNode json = JacksonUtil.toJsonNode(new String(fileData));
List<RuleChainImportResult> results = ruleChainService.importTenantRuleChains(tenantId, ruleChainData, false, tbRuleChainService::updateRuleNodeConfiguration);
log.debug("[{}] Rule chain(s) installed", tenantId); RuleChain ruleChain;
RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor(); try {
if (!results.isEmpty()) { ruleChain = JacksonUtil.fromString(json.get("ruleChain").toString(), RuleChain.class, true);
descriptor.setRuleChainId(results.get(0).getRuleChainId()); } 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; return descriptor;
} }
private DeviceInstalledItemDescriptor installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { 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.setId(null);
deviceProfile.setTenantId(tenantId); deviceProfile.setTenantId(tenantId);
tbDeviceProfileService.save(deviceProfile, user); tbDeviceProfileService.save(deviceProfile, user);
@ -173,6 +213,37 @@ public class DefaultIotHubService implements IotHubService {
return new DeviceInstalledItemDescriptor(); 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 @Override
public void deleteInstalledItem(SecurityUser user, UUID itemId) { public void deleteInstalledItem(SecurityUser user, UUID itemId) {
TenantId tenantId = user.getTenantId(); TenantId tenantId = user.getTenantId();

2
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); InstallItemVersionResult installItemVersion(SecurityUser user, String versionId);
UpdateItemVersionResult updateItemVersion(SecurityUser user, UUID itemId, String versionId);
void deleteInstalledItem(SecurityUser user, UUID itemId); void deleteInstalledItem(SecurityUser user, UUID itemId);
} }

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

22
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 { PageLink } from '@shared/models/page/page-link';
import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.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 { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params';
import { InterceptorConfig } from '@core/interceptors/interceptor-config'; import { InterceptorConfig } from '@core/interceptors/interceptor-config';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -112,14 +112,22 @@ export class IotHubApiService {
); );
} }
public installItemVersion(versionId: string, config?: IotHubRequestConfig): Observable<any> { public installItemVersion(versionId: string, config?: IotHubRequestConfig): Observable<InstallItemVersionResult> {
return this.http.post<any>( return this.http.post<InstallItemVersionResult>(
`/api/iot-hub/versions/${versionId}/install`, `/api/iot-hub/versions/${versionId}/install`,
null, null,
{ params: this.buildParams(config) } { params: this.buildParams(config) }
); );
} }
public updateItemVersion(itemId: string, versionId: string, config?: IotHubRequestConfig): Observable<UpdateItemVersionResult> {
return this.http.post<UpdateItemVersionResult>(
`/api/iot-hub/installedItems/${itemId}/update/${versionId}`,
null,
{ params: this.buildParams(config) }
);
}
public getInstalledItemByItemId(itemId: string, config?: IotHubRequestConfig): Observable<IotHubInstalledItem> { public getInstalledItemByItemId(itemId: string, config?: IotHubRequestConfig): Observable<IotHubInstalledItem> {
return this.http.get<IotHubInstalledItem>( return this.http.get<IotHubInstalledItem>(
`/api/iot-hub/installedItems/byItemId/${itemId}`, `/api/iot-hub/installedItems/byItemId/${itemId}`,
@ -148,6 +156,14 @@ export class IotHubApiService {
); );
} }
public checkForUpdates(infos: IotHubInstalledItemInfo[], config?: IotHubRequestConfig): Observable<ItemUpdateInfo[]> {
return this.http.post<ItemUpdateInfo[]>(
`${this.baseUrl}/api/versions/checkForUpdates`,
infos,
{ params: this.buildParams(config) }
);
}
public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable<CreatorView> { public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable<CreatorView> {
return this.http.get<CreatorView>( return this.http.get<CreatorView>(
`${this.baseUrl}/api/creators/${creatorId}/profile`, `${this.baseUrl}/api/creators/${creatorId}/profile`,

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html

@ -194,9 +194,11 @@
[showTypeChip]="false" [showTypeChip]="false"
[showCreator]="!creatorId" [showCreator]="!creatorId"
[installed]="isInstalled(item)" [installed]="isInstalled(item)"
[installedItemInfo]="installedItemsMap.get(item.itemId) || null"
(cardClick)="openItemDetail($event)" (cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)" (creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"> (installClick)="installItem($event)"
(updateClick)="updateItem($event)">
</tb-iot-hub-item-card> </tb-iot-hub-item-card>
} }
</div> </div>

39
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 { IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core'; 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 { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component';
import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
interface SortOption { interface SortOption {
value: string; value: string;
@ -99,7 +100,8 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
private iotHubApiService: IotHubApiService, private iotHubApiService: IotHubApiService,
private dialog: MatDialog, private dialog: MatDialog,
private translate: TranslateService, private translate: TranslateService,
private router: Router private router: Router,
private route: ActivatedRoute
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@ -111,7 +113,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems(); this.loadItems();
}); });
this.updateCategories(); this.updateCategories();
this.loadInstalledItemInfos(); this.initInstalledItemInfos();
this.loadItems(); this.loadItems();
} }
@ -342,7 +344,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
} as IotHubItemDetailDialogData } as IotHubItemDetailDialogData
}); });
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe(result => {
if (result === 'installed') { if (result === 'installed' || result === 'updated') {
this.loadInstalledItemInfos(); 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 { navigateToCreator(creatorId: string): void {
this.router.navigate(['/iot-hub/creator', creatorId]); this.router.navigate(['/iot-hub/creator', creatorId]);
} }
@ -371,6 +396,12 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.router.navigate(['/iot-hub/installed']); 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 { private loadInstalledItemInfos(): void {
this.iotHubApiService.getInstalledItemInfos({ignoreLoading: true}).subscribe(infos => { this.iotHubApiService.getInstalledItemInfos({ignoreLoading: true}).subscribe(infos => {
this.installedItemsMap.clear(); this.installedItemsMap.clear();

185
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 { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Router } from '@angular/router';
import { AppState } from '@core/core.state';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; 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 { IotHubApiService } from '@core/http/iot-hub-api.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils';
export interface IotHubInstallDialogData { export interface IotHubInstallDialogData {
item: MpItemVersionView; item: MpItemVersionView;
@ -35,36 +36,65 @@ export type InstallState = 'confirm' | 'installing' | 'success' | 'error';
selector: 'tb-iot-hub-install-dialog', selector: 'tb-iot-hub-install-dialog',
standalone: false, standalone: false,
template: ` template: `
<h2 mat-dialog-title>{{ 'iot-hub.install-item-title' | translate }}</h2> @switch (state) {
<mat-dialog-content> @case ('confirm') {
<p>{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}</p> <h2 mat-dialog-title>{{ 'iot-hub.install-item-title' | translate }}</h2>
<p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-type' | translate:{ type: (typeTranslations.get(item.type) | translate) } }}</p> <mat-dialog-content>
<p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}</p> <p>{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}</p>
</mat-dialog-content> <p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-type' | translate:{ type: getTypeLabel() } }}</p>
<mat-dialog-actions align="end"> <p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}</p>
<button mat-button (click)="cancel()" [disabled]="state === 'installing'"> </mat-dialog-content>
{{ 'action.cancel' | translate }} <mat-dialog-actions align="end">
</button> <button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
@switch (state) { <button mat-raised-button color="primary" (click)="install()">{{ 'iot-hub.install' | translate }}</button>
@case ('confirm') { </mat-dialog-actions>
<button mat-raised-button color="primary" (click)="install()"> }
{{ 'iot-hub.install' | translate }} @case ('installing') {
</button> <h2 mat-dialog-title>{{ 'iot-hub.install-item-title' | translate }}</h2>
} <mat-dialog-content>
@case ('installing') { <p>{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}</p>
<p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-type' | translate:{ type: getTypeLabel() } }}</p>
<p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button disabled>{{ 'action.cancel' | translate }}</button>
<button mat-raised-button color="primary" disabled> <button mat-raised-button color="primary" disabled>
<mat-spinner diameter="18" class="tb-iot-hub-inline-spinner"></mat-spinner> <mat-spinner diameter="18" class="tb-iot-hub-inline-spinner"></mat-spinner>
{{ 'iot-hub.installing' | translate }} {{ 'iot-hub.installing' | translate }}
</button> </button>
} </mat-dialog-actions>
@case ('success') { }
<button mat-raised-button color="primary" disabled> @case ('success') {
<mat-icon>check</mat-icon> <h2 mat-dialog-title>
{{ 'iot-hub.installed' | translate }} <mat-icon class="tb-iot-hub-result-icon tb-iot-hub-success-icon">check_circle</mat-icon>
</button> {{ 'iot-hub.install-success-title' | translate }}
} </h2>
<mat-dialog-content>
<p>{{ 'iot-hub.install-success-message' | translate:{ name: item.name, version: item.version } }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="close()">{{ 'action.close' | translate }}</button>
@if (entityDetailsUrl) {
<button mat-raised-button color="primary" (click)="openEntityDetails()">
{{ 'iot-hub.open-item-type-details' | translate:{ type: getTypeLabel() } }}
</button>
}
</mat-dialog-actions>
} }
</mat-dialog-actions> @case ('error') {
<h2 mat-dialog-title>
<mat-icon class="tb-iot-hub-result-icon tb-iot-hub-error-icon">error</mat-icon>
{{ 'iot-hub.install-error-title' | translate }}
</h2>
<mat-dialog-content>
<p>{{ 'iot-hub.install-error-message' | translate:{ name: item.name } }}</p>
<div class="tb-iot-hub-error-details">{{ errorMessage }}</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="close()">{{ 'action.close' | translate }}</button>
</mat-dialog-actions>
}
}
`, `,
styles: [` styles: [`
.tb-iot-hub-install-meta { .tb-iot-hub-install-meta {
@ -76,23 +106,61 @@ export type InstallState = 'confirm' | 'installing' | 'success' | 'error';
display: inline-block; display: inline-block;
margin-right: 8px; 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 { export class TbIotHubInstallDialogComponent {
private static readonly ITEM_TYPE_TO_ENTITY_TYPE: Record<string, EntityType> = {
'WIDGET': EntityType.WIDGET_TYPE,
'DASHBOARD': EntityType.DASHBOARD,
'CALCULATED_FIELD': EntityType.CALCULATED_FIELD,
'RULE_CHAIN': EntityType.RULE_CHAIN,
'DEVICE': EntityType.DEVICE_PROFILE
};
item: MpItemVersionView; item: MpItemVersionView;
typeTranslations = itemTypeTranslations; typeTranslations = itemTypeTranslations;
state: InstallState = 'confirm'; state: InstallState = 'confirm';
errorMessage = '';
entityDetailsUrl: string | null = null;
constructor( constructor(
@Inject(MAT_DIALOG_DATA) public data: IotHubInstallDialogData, @Inject(MAT_DIALOG_DATA) public data: IotHubInstallDialogData,
private dialogRef: MatDialogRef<TbIotHubInstallDialogComponent>, private dialogRef: MatDialogRef<TbIotHubInstallDialogComponent>,
private store: Store<AppState>, private router: Router,
private translate: TranslateService private translate: TranslateService
) { ) {
this.item = data.item; this.item = data.item;
} }
getTypeLabel(): string {
const key = this.typeTranslations.get(this.item.type);
return key ? this.translate.instant(key) : '';
}
install(): void { install(): void {
this.state = 'installing'; this.state = 'installing';
const versionId = this.item.id as string; const versionId = this.item.id as string;
@ -100,33 +168,52 @@ export class TbIotHubInstallDialogComponent {
next: (result) => { next: (result) => {
if (result.success) { if (result.success) {
this.state = 'success'; this.state = 'success';
this.store.dispatch(new ActionNotificationShow({ this.entityDetailsUrl = this.resolveEntityDetailsUrl(result.descriptor);
message: this.translate.instant('iot-hub.install-success', { name: this.item.name }),
type: 'success',
duration: 3000
}));
setTimeout(() => this.dialogRef.close('installed'), 1500);
} else { } else {
this.state = 'confirm'; this.state = 'error';
this.store.dispatch(new ActionNotificationShow({ this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name });
message: result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }),
type: 'error',
duration: 5000
}));
} }
}, },
error: () => { error: (err) => {
this.state = 'confirm'; this.state = 'error';
this.store.dispatch(new ActionNotificationShow({ this.errorMessage = err?.error?.message || err?.message || this.translate.instant('iot-hub.install-error', { name: this.item.name });
message: this.translate.instant('iot-hub.install-error', { name: this.item.name }),
type: 'error',
duration: 5000
}));
} }
}); });
} }
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 { cancel(): void {
this.dialogRef.close(false); 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;
}
} }

25
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<IotHubInstalledItemInfo[]> = () => {
const iotHubApiService = inject(IotHubApiService);
return iotHubApiService.getInstalledItemInfos({ ignoreLoading: true });
};

41
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html

@ -22,6 +22,18 @@
</a> </a>
<div class="tb-iot-hub-installed-items-header"> <div class="tb-iot-hub-installed-items-header">
<h2>{{ 'iot-hub.installed-items' | translate }}</h2> <h2>{{ 'iot-hub.installed-items' | translate }}</h2>
@if (!updatesChecked) {
<button mat-flat-button color="primary"
[disabled]="isCheckingUpdates"
(click)="checkForUpdates()">
@if (isCheckingUpdates) {
<mat-spinner diameter="18" class="tb-iot-hub-inline-spinner"></mat-spinner>
} @else {
<mat-icon>update</mat-icon>
}
{{ 'iot-hub.check-for-updates' | translate }}
</button>
}
</div> </div>
<div class="tb-iot-hub-installed-search-row"> <div class="tb-iot-hub-installed-search-row">
@ -64,7 +76,34 @@
<ng-container matColumnDef="createdTime"> <ng-container matColumnDef="createdTime">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.installed-date' | translate }}</th> <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.installed-date' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.createdTime | date:'medium' }}</td> <td mat-cell *matCellDef="let item">{{ item.createdTime | date:'mediumDate' }}</td>
</ng-container>
<ng-container matColumnDef="updates">
<th mat-header-cell *matHeaderCellDef>{{ updatesChecked ? ('iot-hub.updates' | translate) : '' }}</th>
<td mat-cell *matCellDef="let item">
@if (updatesChecked) {
@if (getUpdateInfo(item); as updateInfo) {
@if (updateInfo.hasUpdate) {
<button mat-stroked-button class="tb-iot-hub-update-button"
[matTooltip]="'iot-hub.view-item-details' | translate"
matTooltipPosition="above"
(click)="viewUpdateDetails(updateInfo, item)">
<mat-icon>info_outline</mat-icon>
{{ updateInfo.latestVersion }}
</button>
<button mat-stroked-button color="primary" class="tb-iot-hub-update-button"
(click)="updateItem(item, updateInfo)">
{{ 'iot-hub.update' | translate }}
</button>
} @else {
<span class="tb-iot-hub-up-to-date">{{ 'iot-hub.up-to-date' | translate }}</span>
}
} @else {
<span class="tb-iot-hub-up-to-date">{{ 'iot-hub.up-to-date' | translate }}</span>
}
}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">

51
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 { .tb-iot-hub-installed-items-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
@ -67,6 +70,11 @@
} }
} }
.tb-iot-hub-inline-spinner {
display: inline-block;
margin-right: 8px;
}
.tb-iot-hub-installed-search-row { .tb-iot-hub-installed-search-row {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -174,7 +182,33 @@
width: 100%; 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 { .mat-column-actions {
width: 144px;
min-width: 144px;
max-width: 144px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
@ -194,6 +228,23 @@
&.tb-type-device { background: #eceff1; color: #455a64; } &.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 { .tb-iot-hub-installed-empty {
padding: 48px 24px; padding: 48px 24px;
text-align: center; text-align: center;

68
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 { 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 { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
@ -29,11 +29,12 @@ import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators'; import { debounceTime } from 'rxjs/operators';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order'; 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 { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils'; import { getEntityDetailsPageURL } from '@core/utils';
import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component'; import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
@Component({ @Component({
selector: 'tb-iot-hub-installed-items', selector: 'tb-iot-hub-installed-items',
@ -43,7 +44,7 @@ import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '.
}) })
export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit {
displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'actions']; displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'updates', 'actions'];
dataSource: IotHubInstalledItem[] = []; dataSource: IotHubInstalledItem[] = [];
totalElements = 0; totalElements = 0;
pageSize = 10; pageSize = 10;
@ -51,6 +52,11 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit {
isLoading = false; isLoading = false;
textSearch = ''; textSearch = '';
installedItemInfos: IotHubInstalledItemInfo[] = [];
updateInfoMap = new Map<string, ItemUpdateInfo>();
updatesChecked = false;
isCheckingUpdates = false;
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
@ViewChild(MatSort, {static: true}) sort: MatSort; @ViewChild(MatSort, {static: true}) sort: MatSort;
@ -70,10 +76,12 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit {
private translate: TranslateService, private translate: TranslateService,
private store: Store<AppState>, private store: Store<AppState>,
private router: Router, private router: Router,
private dialog: MatDialog private dialog: MatDialog,
private route: ActivatedRoute
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.installedItemInfos = this.route.snapshot.data['installedItemInfos'] || [];
this.searchSubject.pipe( this.searchSubject.pipe(
debounceTime(300) debounceTime(300)
).subscribe(() => { ).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 { private loadData(): void {
this.isLoading = true; this.isLoading = true;
const sortOrder: SortOrder = { const sortOrder: SortOrder = {

6
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html

@ -101,7 +101,11 @@
{{ item.totalInstallCount | shortNumber }} {{ item.totalInstallCount | shortNumber }}
</span> </span>
@if (installed) { @if (installed) {
<span class="tb-iot-hub-card-installed-label">{{ 'iot-hub.installed' | translate }}</span> @if (hasUpdate()) {
<button class="tb-iot-hub-card-update-btn" (click)="onUpdateClick($event)">{{ 'iot-hub.update' | translate }}</button>
} @else {
<span class="tb-iot-hub-card-installed-label">{{ 'iot-hub.installed' | translate }}</span>
}
} @else { } @else {
<button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">{{ 'iot-hub.install' | translate }}</button> <button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">{{ 'iot-hub.install' | translate }}</button>
} }

18
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss

@ -358,4 +358,22 @@
font-weight: 600; font-weight: 600;
white-space: nowrap; 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;
}
}
} }

13
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 { ItemType, itemTypeTranslations, getCategoriesForType, useCaseTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models';
@Component({ @Component({
selector: 'tb-iot-hub-item-card', selector: 'tb-iot-hub-item-card',
@ -36,9 +37,11 @@ export class TbIotHubItemCardComponent {
@Input() showCreator = true; @Input() showCreator = true;
@Input() showTypeChip = true; @Input() showTypeChip = true;
@Input() installed = false; @Input() installed = false;
@Input() installedItemInfo: IotHubInstalledItemInfo | null = null;
@Output() cardClick = new EventEmitter<MpItemVersionView>(); @Output() cardClick = new EventEmitter<MpItemVersionView>();
@Output() creatorClick = new EventEmitter<string>(); @Output() creatorClick = new EventEmitter<string>();
@Output() installClick = new EventEmitter<MpItemVersionView>(); @Output() installClick = new EventEmitter<MpItemVersionView>();
@Output() updateClick = new EventEmitter<MpItemVersionView>();
typeTranslations = itemTypeTranslations; typeTranslations = itemTypeTranslations;
@ -187,11 +190,21 @@ export class TbIotHubItemCardComponent {
this.cardClick.emit(this.item); this.cardClick.emit(this.item);
} }
hasUpdate(): boolean {
return this.installed && this.installedItemInfo != null
&& this.installedItemInfo.itemVersionId !== this.item.id;
}
onInstallClick(event: MouseEvent): void { onInstallClick(event: MouseEvent): void {
event.stopPropagation(); event.stopPropagation();
this.installClick.emit(this.item); this.installClick.emit(this.item);
} }
onUpdateClick(event: MouseEvent): void {
event.stopPropagation();
this.updateClick.emit(this.item);
}
onCreatorClick(event: MouseEvent): void { onCreatorClick(event: MouseEvent): void {
event.stopPropagation(); event.stopPropagation();
this.creatorClick.emit(this.item.creatorId); this.creatorClick.emit(this.item.creatorId);

25
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html

@ -174,15 +174,22 @@
<!-- Footer --> <!-- Footer -->
<div class="dlg-footer"> <div class="dlg-footer">
@if (installedDescriptor) { <div class="dlg-footer-actions">
<button class="dlg-install-btn" (click)="openEntityDetails()"> @if (installedDescriptor) {
{{ 'iot-hub.open-item-type-details' | translate:{ type: getTypeLabel() } }} <button class="dlg-install-btn" (click)="openEntityDetails()">
</button> {{ 'iot-hub.open-item-type-details' | translate:{ type: getTypeLabel() } }}
} @else { </button>
<button class="dlg-install-btn" (click)="install()"> } @else {
{{ 'iot-hub.install-item-type' | translate:{ type: getTypeLabel() } }} <button class="dlg-install-btn" (click)="install()">
</button> {{ 'iot-hub.install-item-type' | translate:{ type: getTypeLabel() } }}
} </button>
}
@if (hasUpdate()) {
<button class="dlg-update-btn" (click)="updateItem()">
{{ 'iot-hub.update' | translate }}
</button>
}
</div>
<div class="dlg-creator-info"> <div class="dlg-creator-info">
<span>{{ 'iot-hub.created-by' | translate:{ name: item.creatorDisplayName } }}</span> <span>{{ 'iot-hub.created-by' | translate:{ name: item.creatorDisplayName } }}</span>
<span class="dlg-sep">&middot;</span> <span class="dlg-sep">&middot;</span>

26
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss

@ -386,9 +386,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
.dlg-install-btn { .dlg-footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
width: 100%; width: 100%;
max-width: 400px; }
.dlg-install-btn {
padding: 12px 24px; padding: 12px 24px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@ -404,6 +409,23 @@
} }
} }
.dlg-update-btn {
padding: 12px 24px;
border: 1px solid #e65100;
border-radius: 8px;
background: transparent;
color: #e65100;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #e65100;
color: #fff;
}
}
.dlg-creator-info { .dlg-creator-info {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;

27
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts

@ -25,6 +25,7 @@ import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils'; import { getEntityDetailsPageURL } from '@core/utils';
import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
export interface IotHubItemDetailDialogData { export interface IotHubItemDetailDialogData {
item: MpItemVersionView; item: MpItemVersionView;
@ -46,6 +47,7 @@ export class TbIotHubItemDetailDialogComponent {
typeTranslations = itemTypeTranslations; typeTranslations = itemTypeTranslations;
readmeContent: string = ''; readmeContent: string = '';
installedDescriptor?: IotHubInstalledItemDescriptor; installedDescriptor?: IotHubInstalledItemDescriptor;
installedItemInfo?: IotHubInstalledItemInfo;
private static readonly ITEM_TYPE_TO_ENTITY_TYPE: Record<string, EntityType> = { private static readonly ITEM_TYPE_TO_ENTITY_TYPE: Record<string, EntityType> = {
'WIDGET': EntityType.WIDGET_TYPE, 'WIDGET': EntityType.WIDGET_TYPE,
@ -67,6 +69,7 @@ export class TbIotHubItemDetailDialogComponent {
) { ) {
this.item = data.item; this.item = data.item;
this.installedDescriptor = data.installedDescriptor; this.installedDescriptor = data.installedDescriptor;
this.installedItemInfo = data.installedItemInfo;
if (!this.installedDescriptor && data.installedItemInfo) { if (!this.installedDescriptor && data.installedItemInfo) {
this.data.iotHubApiService.getInstalledItemByItemId(data.installedItemInfo.itemId, {ignoreLoading: true}).subscribe( this.data.iotHubApiService.getInstalledItemByItemId(data.installedItemInfo.itemId, {ignoreLoading: true}).subscribe(
installedItem => this.installedDescriptor = installedItem?.descriptor installedItem => this.installedDescriptor = installedItem?.descriptor
@ -229,6 +232,11 @@ export class TbIotHubItemDetailDialogComponent {
return this.item.dataDescriptor?.nodeCount || 0; return this.item.dataDescriptor?.nodeCount || 0;
} }
hasUpdate(): boolean {
return this.installedItemInfo != null
&& this.installedItemInfo.itemVersionId !== this.item.id;
}
install(): void { install(): void {
const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'], panelClass: ['tb-dialog'],
@ -244,6 +252,25 @@ export class TbIotHubItemDetailDialogComponent {
}); });
} }
updateItem(): void {
const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, {
panelClass: ['tb-dialog'],
data: {
itemId: this.item.itemId,
itemName: this.item.name,
itemType: this.item.type,
version: this.item.version,
versionId: this.item.id,
iotHubApiService: this.data.iotHubApiService
} as IotHubUpdateDialogData
});
dialogRef.afterClosed().subscribe(result => {
if (result === 'updated') {
this.dialogRef.close('updated');
}
});
}
openSignup(): void { openSignup(): void {
window.open('https://iothub.thingsboard.io/signup', '_blank'); window.open('https://iothub.thingsboard.io/signup', '_blank');
} }

7
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts

@ -21,6 +21,7 @@ import { Authority } from '@shared/models/authority.enum';
import { TbIotHubBrowseComponent } from './iot-hub-browse.component'; import { TbIotHubBrowseComponent } from './iot-hub-browse.component';
import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { iotHubInstalledItemInfosResolver } from './iot-hub-installed-item-infos.resolver';
const routes: Routes = [ const routes: Routes = [
{ {
@ -39,6 +40,9 @@ const routes: Routes = [
data: { data: {
auth: [Authority.TENANT_ADMIN], auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.browse' title: 'iot-hub.browse'
},
resolve: {
installedItemInfos: iotHubInstalledItemInfosResolver
} }
}, },
{ {
@ -51,6 +55,9 @@ const routes: Routes = [
label: 'iot-hub.installed-items', label: 'iot-hub.installed-items',
icon: 'inventory_2' icon: 'inventory_2'
} }
},
resolve: {
installedItemInfos: iotHubInstalledItemInfosResolver
} }
}, },
{ {

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts

@ -24,6 +24,7 @@ import { TbIotHubItemDetailDialogComponent } from './iot-hub-item-detail-dialog.
import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.component'; import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.component';
import { TbIotHubItemCardComponent } from './iot-hub-item-card.component'; import { TbIotHubItemCardComponent } from './iot-hub-item-card.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -32,7 +33,8 @@ import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.compo
TbIotHubItemDetailDialogComponent, TbIotHubItemDetailDialogComponent,
TbIotHubInstallDialogComponent, TbIotHubInstallDialogComponent,
TbIotHubItemCardComponent, TbIotHubItemCardComponent,
TbIotHubInstalledItemsComponent TbIotHubInstalledItemsComponent,
TbIotHubUpdateDialogComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

19
ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts

@ -47,11 +47,30 @@ export type IotHubInstalledItemDescriptor =
| RuleChainInstalledItemDescriptor | RuleChainInstalledItemDescriptor
| DeviceInstalledItemDescriptor; | DeviceInstalledItemDescriptor;
export interface InstallItemVersionResult {
success: boolean;
errorMessage: string;
descriptor: IotHubInstalledItemDescriptor;
}
export interface UpdateItemVersionResult {
success: boolean;
errorMessage: string;
descriptor: IotHubInstalledItemDescriptor;
}
export interface IotHubInstalledItemInfo { export interface IotHubInstalledItemInfo {
itemId: string; itemId: string;
itemVersionId: string; itemVersionId: string;
} }
export interface ItemUpdateInfo {
itemId: string;
hasUpdate: boolean;
latestVersion: string;
latestItemVersionId: string;
}
export interface IotHubInstalledItem extends BaseData<{id: string}> { export interface IotHubInstalledItem extends BaseData<{id: string}> {
tenantId: { id: string }; tenantId: { id: string };
itemId: string; itemId: string;

18
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3740,7 +3740,23 @@
"installing": "Installing...", "installing": "Installing...",
"installed": "Installed", "installed": "Installed",
"install-success": "{{name}} installed successfully", "install-success": "{{name}} installed successfully",
"install-success-title": "Installation Successful",
"install-success-message": "'{{name}}' v{{version}} has been installed successfully.",
"install-error": "Failed to install {{name}}", "install-error": "Failed to install {{name}}",
"install-error-title": "Installation Failed",
"install-error-message": "Failed to install '{{name}}'.",
"check-for-updates": "Check for updates",
"updates": "Updates",
"up-to-date": "Up to date",
"update": "Update",
"update-item-title": "Update Item",
"update-confirm": "Update '{{name}}' to version {{version}}?",
"updating": "Updating...",
"update-success-title": "Update Successful",
"update-success-message": "'{{name}}' has been updated to version {{version}}.",
"update-error-title": "Update Failed",
"update-error-message": "Failed to update '{{name}}'.",
"update-error": "Failed to update {{name}}",
"category.charts-graphs": "Charts & Graphs", "category.charts-graphs": "Charts & Graphs",
"category.gauges-indicators": "Gauges & Indicators", "category.gauges-indicators": "Gauges & Indicators",
"category.controls": "Controls", "category.controls": "Controls",
@ -3790,7 +3806,7 @@
"item-version": "Version", "item-version": "Version",
"installed-date": "Installed", "installed-date": "Installed",
"actions": "Actions", "actions": "Actions",
"no-installed-items": "No installed items", "no-installed-items": "No installed items found",
"delete-installed-item-title": "Delete Installed Item", "delete-installed-item-title": "Delete Installed Item",
"delete-installed-item-text": "Are you sure you want to delete '{{name}}'? The installed entity will also be removed.", "delete-installed-item-text": "Are you sure you want to delete '{{name}}'? The installed entity will also be removed.",
"installed-item-deleted": "'{{name}}' has been deleted", "installed-item-deleted": "'{{name}}' has been deleted",

Loading…
Cancel
Save