From e05b56b9a4546cfc33f1f1b30ee6aefa9a3af051 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 2 Apr 2026 12:49:57 +0300 Subject: [PATCH] Feature/device library (#15347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add device-package.models.ts with install wizard types Defines ConnectivityType, InstallStepType, ENTITY_STEP_TYPES, stepTypeAliasMap, DevicePackageInfo, DeviceInstallStep, form field interfaces, and EntityStepProgress types for the device install wizard. * Add device install wizard dialog component for IoT Hub Multi-step dialog that provisions TB entities from a device package ZIP: - Connectivity type selector, instruction/markdown views, dynamic forms - Entity creation with progress tracking (device profile, device, dashboard, rule chain) - Variable resolution across steps (form values, entity outputs, positional refs) * fix(iot-hub): fix device install dialog template and translations - Use correct tb-markdown inputs ([data] and [usePlainMarkdown]) - Fix translation key format (hyphens, not dots) - Remove unused ConnectivityType import and resolveImageUrl method - Auto-advance to first step when single connectivity type - Use inline data URIs for helpImage directly * feat(iot-hub): wire device install dialog into all install entry points Branch DEVICE item type to fetch ZIP and open TbDeviceInstallDialogComponent instead of the simple install dialog. Applied to: - Item detail dialog (install button) - Browse component (card install button) - Home component (card install button) * fix(iot-hub): fix build errors in device install dialog - Fix tb-markdown input: [markdownText] → [data] in done view - Fix JSZip entry type annotation for strict TS compilation * fix(iot-hub): reuse existing device profiles and add missing retry translation - Find device profile by name before creating; reuse if exists - Add action.retry translation key (was showing raw key) * feat(iot-hub): add find-or-create for rule chains in device install Reuse existing rule chains by name (same pattern as device profiles). Shared entities (device profile, rule chain) are looked up before creation. Per-install entities (device, dashboard) are always created fresh. * feat(iot-hub): refactor device install dialog to use mat-stepper - Replace single-panel view switching with mat-horizontal-stepper - Group consecutive entity steps into a single "Provisioning" stepper step - Each SHOW_INSTRUCTION and SHOW_FORM is its own stepper step - Add 2s minimum display time per entity creation step - Connectivity selector shown before stepper (not a stepper step) - Progress step auto-advances to next step after completion - Stepper shows completed checkmarks for finished steps * fix(iot-hub): fix stepper navigation by tracking step completion Linear mat-stepper requires steps to be marked completed before advancing. Added completed flag to WizardStep, set it when user clicks Next or when entity steps finish. * feat(iot-hub): add device package install tracking with entity cleanup Backend: - Expand DeviceInstalledItemDescriptor with createdEntityIds and dashboardId - Add POST /api/iot-hub/device/register endpoint to save installed item - Implement reverse-order entity deletion for DEVICE uninstall - Update DEVICE case in updateItemVersion to clean old entities Frontend: - Expand DeviceInstalledItemDescriptor TS model - Add registerDeviceInstall API method - Wire wizard to collect entity IDs and register after completion - Enable dashboard navigation for installed device packages * fix(iot-hub): add type discriminator to device register request body Jackson requires the 'type' field for @JsonTypeInfo polymorphic deserialization of DeviceInstalledItemDescriptor. * feat(iot-hub): show entity type in provisioning progress rows Each progress row now displays "Device Profile — ESP32 Dev Kit" instead of just "ESP32 Dev Kit", making it clear what type of entity is being provisioned. * fix(iot-hub): suppress global error toast and add Back button on provisioning error - Add ignoreErrors: true to all entity creation API calls so errors are only shown in the dialog, not as global toast notifications - Add Back button on provisioning error to navigate back to the last form step, allowing users to fix parameters (e.g. device name) - Make form steps editable so stepper allows backward navigation - Reset progress step state when going back so it re-runs with updated form values * fix(iot-hub): resolve ExpressionChangedAfterItHasBeenCheckedError in device install dialog Inject ChangeDetectorRef and call detectChanges() after async state mutations (entity step status changes, stepper auto-advance, ngOnInit completion). The NG0100 error occurred because async operations (setTimeout, HTTP calls) mutated component state outside Angular's change detection cycle. * fix(iot-hub): fix device register endpoint JSON deserialization Accept JsonNode and use JacksonUtil.treeToValue() instead of direct @RequestBody DeviceInstalledItemDescriptor. The @JsonTypeInfo on the parent interface caused Spring's message converter to fail when deserializing the concrete class directly. * fix(iot-hub): fix dashboardId serialization in device register request DashboardId implements EntityId which uses EntityIdDeserializer requiring both entityType and id fields. Frontend was sending {"id":"..."} without entityType, causing "Missing entityType or id!" 400 error. Fix: include entityType: "DASHBOARD" in the dashboardId payload. Add deserialization unit test to verify the exact frontend payload format. --- .../server/controller/IotHubController.java | 12 + .../service/iot_hub/DefaultIotHubService.java | 89 ++- .../server/service/iot_hub/IotHubService.java | 3 + .../DeviceInstalledItemDescriptor.java | 7 + .../DeviceInstalledItemDescriptorTest.java | 75 +++ .../src/app/core/http/iot-hub-api.service.ts | 12 + .../device-install-dialog.component.html | 233 ++++++++ .../device-install-dialog.component.scss | 233 ++++++++ .../device-install-dialog.component.ts | 527 ++++++++++++++++++ .../pages/iot-hub/iot-hub-browse.component.ts | 24 + .../pages/iot-hub/iot-hub-home.component.ts | 24 + .../iot-hub-installed-items.component.ts | 2 + .../iot-hub-item-detail-dialog.component.ts | 29 + .../home/pages/iot-hub/iot-hub.module.ts | 4 +- .../models/iot-hub/device-package.models.ts | 134 +++++ .../iot-hub/iot-hub-installed-item.models.ts | 2 + .../assets/locale/locale.constant-en_US.json | 20 +- 17 files changed, 1424 insertions(+), 6 deletions(-) create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts 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 62989a1c3c..9271839004 100644 --- a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -31,6 +31,8 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletRequest; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -64,6 +66,16 @@ public class IotHubController extends BaseController { return iotHubService.installItemVersion(getCurrentUser(), versionId, data, request); } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/device/register") + @ResponseBody + public InstallItemVersionResult registerDeviceInstall( + @RequestParam String versionId, + @RequestBody JsonNode body) throws ThingsboardException { + DeviceInstalledItemDescriptor descriptor = JacksonUtil.treeToValue(body, DeviceInstalledItemDescriptor.class); + return iotHubService.registerDeviceInstall(getCurrentUser(), versionId, descriptor); + } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/installedItems/{installedItemId}/update/{versionId}") @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 5c2dfd937a..77cd7588cb 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 @@ -24,8 +24,12 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.iot_hub.CalculatedFieldInstalledItemDescriptor; import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor; @@ -45,12 +49,14 @@ import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; +import org.thingsboard.server.service.entitiy.device.TbDeviceService; import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; import org.thingsboard.server.service.rule.TbRuleChainService; @@ -58,6 +64,8 @@ import org.thingsboard.server.service.security.model.SecurityUser; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; import java.util.HexFormat; import java.util.List; import java.util.UUID; @@ -80,6 +88,8 @@ public class DefaultIotHubService implements IotHubService { private final DashboardService dashboardService; private final CalculatedFieldService calculatedFieldService; private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final TbDeviceService tbDeviceService; private final SolutionService solutionService; @Override @@ -283,7 +293,12 @@ public class DefaultIotHubService implements IotHubService { case "DASHBOARD" -> updateDashboard(user, tenantId, (DashboardInstalledItemDescriptor) descriptor, fileData); case "CALCULATED_FIELD" -> updateCalculatedField(user, tenantId, (CalculatedFieldInstalledItemDescriptor) descriptor, fileData); case "RULE_CHAIN" -> updateRuleChain(tenantId, (RuleChainInstalledItemDescriptor) descriptor, fileData); - case "DEVICE" -> updateDeviceProfile(user, tenantId, fileData); + case "DEVICE" -> { + DeviceInstalledItemDescriptor dd = (DeviceInstalledItemDescriptor) descriptor; + deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user); + dd.setCreatedEntityIds(null); + dd.setDashboardId(null); + } case "SOLUTION_TEMPLATE" -> { SolutionTemplateInstalledItemDescriptor stDescriptor = (SolutionTemplateInstalledItemDescriptor) descriptor; solutionService.deleteSolution(tenantId, stDescriptor.getCreatedEntityIds(), user); @@ -474,6 +489,74 @@ public class DefaultIotHubService implements IotHubService { } } + @Override + public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Registering device package install for version: {}", tenantId, versionId); + + try { + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + String itemName = versionInfo.get("name").asText(); + UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); + String version = versionInfo.get("version").asText(); + + IotHubInstalledItem installedItem = new IotHubInstalledItem(); + installedItem.setTenantId(tenantId); + installedItem.setItemId(itemId); + installedItem.setItemVersionId(UUID.fromString(versionId)); + installedItem.setItemName(itemName); + installedItem.setItemType("DEVICE"); + installedItem.setVersion(version); + installedItem.setDescriptor(descriptor); + iotHubInstalledItemService.save(tenantId, installedItem); + + iotHubRestClient.reportVersionInstalled(versionId); + log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version); + + return InstallItemVersionResult.success(descriptor); + } catch (Exception e) { + log.error("[{}] Failed to register device package install: {}", tenantId, versionId, e); + return InstallItemVersionResult.error(e.getMessage()); + } + } + + private void deleteDevicePackageEntities(TenantId tenantId, List createdEntityIds, SecurityUser user) { + if (createdEntityIds == null || createdEntityIds.isEmpty()) { + return; + } + List reversed = new ArrayList<>(createdEntityIds); + Collections.reverse(reversed); + for (EntityId entityId : reversed) { + try { + deleteDevicePackageEntity(tenantId, entityId, user); + } catch (Exception e) { + log.error("[{}] Failed to delete device package entity: {}", tenantId, entityId, e); + } + } + } + + private void deleteDevicePackageEntity(TenantId tenantId, EntityId entityId, SecurityUser user) { + switch (entityId.getEntityType()) { + case DEVICE -> { + var device = deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId())); + if (device != null) tbDeviceService.delete(device, user); + } + case DEVICE_PROFILE -> { + var profile = deviceProfileService.findDeviceProfileById(tenantId, new DeviceProfileId(entityId.getId())); + if (profile != null) tbDeviceProfileService.delete(profile, user); + } + case DASHBOARD -> { + var dashboard = dashboardService.findDashboardById(tenantId, new DashboardId(entityId.getId())); + if (dashboard != null) tbDashboardService.delete(dashboard, user); + } + case RULE_CHAIN -> { + var ruleChain = ruleChainService.findRuleChainById(tenantId, new RuleChainId(entityId.getId())); + if (ruleChain != null) tbRuleChainService.delete(ruleChain, user); + } + default -> log.warn("[{}] Unsupported entity type for device package delete: {}", tenantId, entityId.getEntityType()); + } + } + @Override public void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId) { TenantId tenantId = user.getTenantId(); @@ -505,8 +588,8 @@ public class DefaultIotHubService implements IotHubService { tbRuleChainService.delete(ruleChain, user); } } - } else if (descriptor instanceof DeviceInstalledItemDescriptor) { - // no entity to delete for now + } else if (descriptor instanceof DeviceInstalledItemDescriptor dd) { + deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user); } else if (descriptor instanceof SolutionTemplateInstalledItemDescriptor st) { solutionService.deleteSolution(tenantId, st.getCreatedEntityIds(), user); } 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 0abc8f7756..e2f0ff0c39 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 @@ -18,6 +18,7 @@ package org.thingsboard.server.service.iot_hub; import com.fasterxml.jackson.databind.JsonNode; import jakarta.servlet.http.HttpServletRequest; import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; import org.thingsboard.server.service.security.model.SecurityUser; public interface IotHubService { @@ -26,5 +27,7 @@ public interface IotHubService { UpdateItemVersionResult updateItemVersion(SecurityUser user, IotHubInstalledItemId installedItemId, String versionId, boolean force, HttpServletRequest request); + InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor); + void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java index b558058db5..6b345f9e59 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java @@ -16,8 +16,15 @@ package org.thingsboard.server.common.data.iot_hub; import lombok.Data; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.List; @Data public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + private List createdEntityIds; + private DashboardId dashboardId; + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java new file mode 100644 index 0000000000..f427f17b79 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java @@ -0,0 +1,75 @@ +/** + * 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.common.data.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DashboardId; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeviceInstalledItemDescriptorTest { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Test + void deserializeFromFrontendPayload() throws Exception { + // Exact payload the frontend sends after the fix + String json = """ + { + "type": "DEVICE", + "createdEntityIds": [ + {"entityType": "DEVICE_PROFILE", "id": "3eb9c330-2e57-11f1-9334-a385172e8e7d"}, + {"entityType": "DEVICE", "id": "8a90d5e0-2e5d-11f1-a802-c35a9af2ebde"}, + {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + ], + "dashboardId": {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + } + """; + + // Deserialize the same way the controller does (JsonNode → treeToValue) + JsonNode node = mapper.readTree(json); + DeviceInstalledItemDescriptor descriptor = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.getCreatedEntityIds()).hasSize(3); + assertThat(descriptor.getCreatedEntityIds().get(0).getEntityType()).isEqualTo(EntityType.DEVICE_PROFILE); + assertThat(descriptor.getCreatedEntityIds().get(1).getEntityType()).isEqualTo(EntityType.DEVICE); + assertThat(descriptor.getCreatedEntityIds().get(2).getEntityType()).isEqualTo(EntityType.DASHBOARD); + assertThat(descriptor.getDashboardId()).isNotNull(); + assertThat(descriptor.getDashboardId().getId()).isEqualTo(UUID.fromString("8bc229f0-2e5d-11f1-a802-c35a9af2ebde")); + } + + @Test + void roundTripSerialization() throws Exception { + DeviceInstalledItemDescriptor original = new DeviceInstalledItemDescriptor(); + DashboardId dashboardId = new DashboardId(UUID.randomUUID()); + original.setDashboardId(dashboardId); + original.setCreatedEntityIds(java.util.List.of(dashboardId)); + + String json = mapper.writeValueAsString(original); + JsonNode node = mapper.readTree(json); + DeviceInstalledItemDescriptor deserialized = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); + + assertThat(deserialized.getDashboardId().getId()).isEqualTo(original.getDashboardId().getId()); + assertThat(deserialized.getCreatedEntityIds()).hasSize(1); + assertThat(deserialized.getCreatedEntityIds().get(0).getId()).isEqualTo(dashboardId.getId()); + } +} 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 a71c6811e2..1b902fb26d 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 @@ -112,6 +112,18 @@ export class IotHubApiService { ); } + public registerDeviceInstall( + versionId: string, + descriptor: { type?: string; createdEntityIds: { entityType: string; id: string }[]; dashboardId?: { entityType: string; id: string } }, + config?: IotHubRequestConfig + ): Observable { + return this.http.post( + `/api/iot-hub/device/register?versionId=${versionId}`, + { type: 'DEVICE', ...descriptor }, + { params: this.buildParams(config) } + ); + } + public installItemVersion(versionId: string, config?: IotHubRequestConfig, data?: any): Observable { return this.http.post( `/api/iot-hub/versions/${versionId}/install`, diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html new file mode 100644 index 0000000000..5ee5463c3f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -0,0 +1,233 @@ + +@if (loading) { +
+ +
+} @else { +
+

{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}

+ @if (selectedConnectivity && wizardStarted) { + {{ connectivityLabels.get(selectedConnectivity) }} + } + +
+ + + @if (!wizardStarted) { + +
+
+

{{ 'iot-hub.device-install-select-connectivity' | translate }}

+
+ @for (ct of availableConnectivityTypes; track ct) { + + } +
+
+
+ + + + + } @else { + +
+ + + done + + + @for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) { + + {{ ws.label }} + +
+ @switch (ws.type) { + + @case ('instruction') { +
+ + +
+ } + + @case ('form') { +
+ @if (ws.formGroup) { +
+ @for (field of ws.formFields; track field.key) { + @switch (field.type) { + @case ('BOOLEAN') { + + {{ field.label }} + + } + @case ('SELECT') { + + {{ field.label }} + + @for (opt of field.options; track opt.value) { + {{ opt.label }} + } + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + + } + @case ('PASSWORD') { + + {{ field.label }} + + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @case ('INTEGER') { + + {{ field.label }} + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @default { + + {{ field.label }} + + @if (ws.formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + } + @if (field.helpText) { +
{{ field.helpText }}
+ } + @if (field.helpImage) { + + } + } +
+ } +
+ } + + @case ('progress') { +
+ @for (ep of ws.entitySteps; track ep.step.name) { +
+
+ @switch (ep.status) { + @case ('pending') { + radio_button_unchecked + } + @case ('running') { + + } + @case ('success') { + check_circle + } + @case ('error') { + error + } + } +
+
+ {{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }} + @if (ep.status === 'error' && ep.errorMessage) { + {{ ep.errorMessage }} + } +
+
+ } +
+ } + } +
+
+ } +
+
+ + + @if (currentWizardStep; as step) { + @switch (step.type) { + @case ('instruction') { + + + } + @case ('form') { + + + } + @case ('progress') { + @if (step.progressError) { + + + + } @else if (step.progressDone && isLastWizardStep) { + + } + } + } + } + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss new file mode 100644 index 0000000000..2a764ccb12 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -0,0 +1,233 @@ +/** + * 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. + */ + +:host { + display: flex; + flex-direction: column; + width: 720px; + max-width: 90vw; + max-height: 85vh; +} + +.tb-device-install-header { + display: flex; + align-items: center; + padding: 16px 24px; + gap: 12px; + + .tb-device-install-title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .tb-device-install-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + background: #e3f2fd; + color: #1565c0; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + } +} + +.tb-device-install-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 0; +} + +// Connectivity selector (before stepper) +.tb-device-install-body { + flex: 1; + overflow-y: auto; + padding: 0 24px 24px; + min-height: 120px; +} + +.tb-device-install-connectivity { + display: flex; + flex-direction: column; + gap: 12px; + + p { + color: rgba(0, 0, 0, 0.54); + font-size: 14px; + line-height: 20px; + margin: 0; + } + + .tb-connectivity-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .tb-connectivity-button { + min-width: 100px; + } + + .tb-connectivity-button.selected { + background: #305680; + color: #fff; + } +} + +// Stepper container +.tb-device-install-stepper-container { + flex: 1; + overflow-y: auto; + min-height: 200px; + + ::ng-deep { + .mat-horizontal-stepper-header-container { + padding: 0 16px; + } + + .mat-horizontal-content-container { + padding: 0 24px 16px; + } + + .mat-step-header { + padding: 0 12px; + } + } +} + +// Step content wrapper +.tb-wizard-step-content { + min-height: 120px; +} + +// Instruction view +.tb-device-install-instruction { + ::ng-deep tb-markdown { + display: block; + } + + ::ng-deep { + h1, h2, h3, h4 { + &:first-child { + margin-top: 0; + } + } + } +} + +// Form view +.tb-device-install-form { + display: flex; + flex-direction: column; + gap: 8px; + + mat-form-field { + width: 100%; + } + + .tb-form-field-help { + color: rgba(0, 0, 0, 0.54); + font-size: 12px; + line-height: 16px; + margin-top: -4px; + margin-bottom: 8px; + } + + .tb-form-field-help-image { + max-width: 100%; + margin-top: 4px; + margin-bottom: 8px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + } +} + +// Progress view +.tb-device-install-progress { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tb-progress-row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px 0; +} + +.tb-progress-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + .tb-progress-pending { + color: rgba(0, 0, 0, 0.38); + } + + .tb-progress-success { + color: #2e7d32; + } + + .tb-progress-error { + color: #c62828; + } +} + +.tb-progress-info { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 24px; + justify-content: center; +} + +.tb-progress-label { + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.87); +} + +.tb-progress-error-msg { + font-size: 12px; + color: #c62828; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; +} + +mat-dialog-actions { + padding: 8px 24px 16px; +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts new file mode 100644 index 0000000000..7223e3fa6d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -0,0 +1,527 @@ +/// +/// 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 { ChangeDetectorRef, Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatStepper } from '@angular/material/stepper'; +import { firstValueFrom } from 'rxjs'; +import { PageLink } from '@shared/models/page/page-link'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { DeviceProfileService } from '@core/http/device-profile.service'; +import { DeviceService } from '@core/http/device.service'; +import { DashboardService } from '@core/http/dashboard.service'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { + connectivityTypeTranslations, + DeviceInstallStep, + DevicePackageInfo, + ENTITY_STEP_TYPES, + EntityStepOutput, + EntityStepProgress, + FormFieldDefinition, + FormFieldType, + InstallStepType, + stepTypeAliasMap +} from '@shared/models/iot-hub/device-package.models'; + +export interface DeviceInstallDialogData { + item: MpItemVersionView; + zipData: ArrayBuffer; + iotHubApiService: IotHubApiService; +} + +export type WizardStepType = 'instruction' | 'form' | 'progress'; + +export interface WizardStep { + type: WizardStepType; + label: string; + rawSteps: DeviceInstallStep[]; + completed: boolean; + // Instruction + markdown?: string; + // Form + formFields?: FormFieldDefinition[]; + formGroup?: UntypedFormGroup; + // Progress + entitySteps?: EntityStepProgress[]; + progressError?: string; + progressDone?: boolean; +} + +const ENTITY_STEP_MIN_DELAY = 2000; + +@Component({ + selector: 'tb-device-install-dialog', + standalone: false, + templateUrl: './device-install-dialog.component.html', + styleUrls: ['./device-install-dialog.component.scss'] +}) +export class TbDeviceInstallDialogComponent implements OnInit { + + @ViewChild('installStepper', {static: false}) stepper: MatStepper; + + loading = true; + packageInfo: DevicePackageInfo; + zipFiles = new Map(); + + // Connectivity + showConnectivitySelector = false; + availableConnectivityTypes: string[] = []; + selectedConnectivity: string | null = null; + connectivityLabels = connectivityTypeTranslations; + + // Wizard + wizardSteps: WizardStep[] = []; + wizardStarted = false; + passwordVisible: Record = {}; + + // Variable resolution state + formValues: Record = {}; + entityOutputs = new Map(); + positionalOutputs = new Map(); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData, + private dialogRef: MatDialogRef, + private cdr: ChangeDetectorRef, + private deviceProfileService: DeviceProfileService, + private deviceService: DeviceService, + private dashboardService: DashboardService, + private ruleChainService: RuleChainService + ) {} + + async ngOnInit(): Promise { + try { + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(this.data.zipData); + for (const [path, entry] of Object.entries(zip.files) as [string, any][]) { + if (!entry.dir) { + const content = await entry.async('string'); + this.zipFiles.set(path, content); + } + } + this.packageInfo = JSON.parse(this.zipFiles.get('device-info.json')); + this.availableConnectivityTypes = this.packageInfo.connectivityTypes.filter( + ct => this.packageInfo.installSteps[ct]?.length > 0 + ); + if (this.availableConnectivityTypes.length === 1) { + this.selectedConnectivity = this.availableConnectivityTypes[0]; + this.showConnectivitySelector = false; + this.startWizard(); + } else { + this.showConnectivitySelector = true; + } + } catch (e) { + console.error('Failed to parse device package ZIP', e); + } + this.loading = false; + this.cdr.detectChanges(); + } + + // --- Connectivity --- + + selectConnectivity(ct: string): void { + this.selectedConnectivity = ct; + } + + confirmConnectivity(): void { + if (!this.selectedConnectivity) { + return; + } + this.startWizard(); + } + + // --- Wizard --- + + get isLastWizardStep(): boolean { + return this.stepper && this.stepper.selectedIndex === this.wizardSteps.length - 1; + } + + get currentWizardStep(): WizardStep | null { + if (!this.stepper || !this.wizardSteps.length) { + return null; + } + return this.wizardSteps[this.stepper.selectedIndex] ?? null; + } + + nextStep(): void { + const step = this.currentWizardStep; + if (!step) { + return; + } + if (step.type === 'form') { + if (step.formGroup?.invalid) { + step.formGroup.markAllAsTouched(); + return; + } + Object.assign(this.formValues, step.formGroup.getRawValue()); + } + if (this.isLastWizardStep) { + this.done(); + return; + } + step.completed = true; + this.stepper.next(); + this.onStepActivated(); + } + + done(): void { + this.dialogRef.close('installed'); + } + + cancel(): void { + this.dialogRef.close(false); + } + + retryEntitySteps(step: WizardStep): void { + step.progressError = null; + this.runEntitySteps(step); + } + + goBackToForm(): void { + // Find the last form step before the current progress step + const currentIdx = this.stepper.selectedIndex; + for (let i = currentIdx - 1; i >= 0; i--) { + if (this.wizardSteps[i].type === 'form') { + // Reset the progress step so it re-runs when we come back + const progressStep = this.wizardSteps[currentIdx]; + progressStep.entitySteps = null; + progressStep.progressError = null; + progressStep.progressDone = false; + progressStep.completed = false; + // Allow navigation back by making intermediate steps editable + for (let j = i; j < currentIdx; j++) { + this.wizardSteps[j].completed = false; + } + this.stepper.selectedIndex = i; + return; + } + } + } + + getPatternErrorMessage(field: FormFieldDefinition): string { + return field.validators?.[0]?.message || 'Invalid format'; + } + + // --- Variable Resolution --- + + resolveVariables(content: string): string { + return content.replace(/\$\{([^}]+)\}/g, (_match, key) => { + if (key in this.formValues) { + return String(this.formValues[key]); + } + const dotIdx = key.indexOf('.'); + if (dotIdx > 0) { + const alias = key.substring(0, dotIdx); + const prop = key.substring(dotIdx + 1); + const output = this.entityOutputs.get(alias); + if (output && prop in output) { + return String((output as any)[prop]); + } + const m = alias.match(/^step(\d+)$/); + if (m) { + const pos = this.positionalOutputs.get(parseInt(m[1], 10)); + if (pos && prop in pos) { + return String((pos as any)[prop]); + } + } + } + return '${' + key + '}'; + }); + } + + // --- Private --- + + private startWizard(): void { + this.formValues = {}; + this.entityOutputs.clear(); + this.positionalOutputs.clear(); + this.passwordVisible = {}; + this.buildWizardSteps(); + this.wizardStarted = true; + + // Activate the first step after the stepper renders + setTimeout(() => this.onStepActivated(), 0); + } + + private buildWizardSteps(): void { + const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || []; + this.wizardSteps = []; + let i = 0; + while (i < rawSteps.length) { + const step = rawSteps[i]; + if (step.type === InstallStepType.SHOW_INSTRUCTION) { + this.wizardSteps.push({ + type: 'instruction', + label: step.name, + rawSteps: [step], + completed: false + }); + i++; + } else if (step.type === InstallStepType.SHOW_FORM) { + this.wizardSteps.push({ + type: 'form', + label: step.name, + rawSteps: [step], + completed: false + }); + i++; + } else if (ENTITY_STEP_TYPES.has(step.type)) { + // Group consecutive entity steps + const group: DeviceInstallStep[] = []; + while (i < rawSteps.length && ENTITY_STEP_TYPES.has(rawSteps[i].type)) { + group.push(rawSteps[i]); + i++; + } + this.wizardSteps.push({ + type: 'progress', + label: 'Provisioning', + rawSteps: group, + completed: false + }); + } else { + // Skip unsupported steps (CONVERTER, INTEGRATION) + i++; + } + } + // Initialize form groups + for (const ws of this.wizardSteps) { + if (ws.type === 'form') { + this.initFormStep(ws); + } + } + } + + private initFormStep(ws: WizardStep): void { + const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]'; + ws.formFields = JSON.parse(formJson) as FormFieldDefinition[]; + const controls: Record = {}; + for (const field of ws.formFields) { + const validators = []; + if (field.required) { + validators.push(Validators.required); + } + if (field.validators?.length > 0) { + validators.push(Validators.pattern(field.validators[0].pattern)); + } + const initialValue = field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); + controls[field.key] = new UntypedFormControl(initialValue, validators); + if (field.type === FormFieldType.PASSWORD) { + this.passwordVisible[field.key] = false; + } + } + ws.formGroup = new UntypedFormGroup(controls); + } + + private onStepActivated(): void { + const step = this.currentWizardStep; + if (!step) { + return; + } + if (step.type === 'instruction') { + const raw = this.zipFiles.get(step.rawSteps[0].file) || ''; + step.markdown = this.resolveVariables(raw); + } else if (step.type === 'progress' && !step.progressDone) { + this.initAndRunEntitySteps(step); + } + } + + private initAndRunEntitySteps(ws: WizardStep): void { + ws.entitySteps = ws.rawSteps.map(s => ({ + step: s, + status: 'pending' as const, + resolvedName: this.resolveVariables(s.name) + })); + ws.progressError = null; + ws.progressDone = false; + this.runEntitySteps(ws); + } + + private async runEntitySteps(ws: WizardStep): Promise { + for (const ep of ws.entitySteps) { + if (ep.status === 'success') { + continue; + } + ep.status = 'running'; + ep.errorMessage = null; + this.cdr.detectChanges(); + try { + const startTime = Date.now(); + const output = await this.createEntity(ep.step); + // Ensure minimum visible time per step + const elapsed = Date.now() - startTime; + if (elapsed < ENTITY_STEP_MIN_DELAY) { + await this.delay(ENTITY_STEP_MIN_DELAY - elapsed); + } + ep.entityOutput = output; + ep.status = 'success'; + + const alias = stepTypeAliasMap[ep.step.type]; + if (alias) { + this.entityOutputs.set(alias, output); + } + const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || []; + const stepIdx = rawSteps.indexOf(ep.step); + if (stepIdx >= 0) { + this.positionalOutputs.set(stepIdx + 1, output); + } + + // Re-resolve names of remaining pending steps + for (const remaining of ws.entitySteps) { + if (remaining.status === 'pending') { + remaining.resolvedName = this.resolveVariables(remaining.step.name); + } + } + this.cdr.detectChanges(); + } catch (err: any) { + ep.status = 'error'; + ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; + ws.progressError = ep.errorMessage; + this.cdr.detectChanges(); + return; + } + } + + // All done — register install with created entity IDs + ws.progressDone = true; + ws.completed = true; + try { + const createdEntityIds = this.collectCreatedEntityIds(); + const dashboardId = this.findCreatedDashboardId(); + await firstValueFrom( + this.data.iotHubApiService.registerDeviceInstall( + this.data.item.id as string, + { createdEntityIds, dashboardId }, + { ignoreLoading: true } + ) + ); + } catch (_e) { + // Non-critical — entities are created, tracking registration failed + console.error('Failed to register device install', _e); + } + + // Auto-advance to next step after a short pause + if (!this.isLastWizardStep) { + await this.delay(500); + this.stepper.next(); + this.cdr.detectChanges(); + this.onStepActivated(); + } + } + + private async createEntity(step: DeviceInstallStep): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) { + throw new Error(`Template file not found: ${step.template}`); + } + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: { + const existing = await this.findDeviceProfileByName(template.name); + if (existing) { + return existing; + } + const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.name }; + } + case InstallStepType.DEVICE: { + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id, false, {ignoreErrors: true})); + return { id: result.id.id, name: result.name, token: creds.credentialsId }; + } + case InstallStepType.DASHBOARD: { + const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.title }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + const existing = await this.findRuleChainByName(ruleChain.name); + if (existing) { + return existing; + } + const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true})); + if (metadata) { + metadata.ruleChainId = saved.id; + await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true})); + } + return { id: saved.id.id, name: saved.name }; + } + default: + throw new Error(`Unsupported entity step type: ${step.type}`); + } + } + + private async findDeviceProfileByName(name: string): Promise { + const profiles = await firstValueFrom(this.deviceProfileService.getDeviceProfileNames()); + const match = profiles.find(p => p.name === name); + return match ? { id: match.id.id, name: match.name } : null; + } + + private async findRuleChainByName(name: string): Promise { + const page = await firstValueFrom(this.ruleChainService.getRuleChains(new PageLink(100, 0, name))); + const match = page.data.find(rc => rc.name === name); + return match ? { id: match.id.id, name: match.name } : null; + } + + private collectCreatedEntityIds(): { entityType: string; id: string }[] { + const ids: { entityType: string; id: string }[] = []; + for (const ws of this.wizardSteps) { + if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.status === 'success' && ep.entityOutput) { + const entityType = this.stepTypeToEntityType(ep.step.type); + if (entityType) { + ids.push({ entityType, id: ep.entityOutput.id }); + } + } + } + } + } + return ids; + } + + private findCreatedDashboardId(): { entityType: string; id: string } | undefined { + for (const ws of this.wizardSteps) { + if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.step.type === InstallStepType.DASHBOARD && ep.status === 'success' && ep.entityOutput) { + return { entityType: 'DASHBOARD', id: ep.entityOutput.id }; + } + } + } + } + return undefined; + } + + private stepTypeToEntityType(stepType: InstallStepType): string | null { + switch (stepType) { + case InstallStepType.DEVICE_PROFILE: return 'DEVICE_PROFILE'; + case InstallStepType.DEVICE: return 'DEVICE'; + case InstallStepType.DASHBOARD: return 'DASHBOARD'; + case InstallStepType.RULE_CHAIN: return 'RULE_CHAIN'; + default: return null; + } + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} 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 7c19cc96ee..0c96e89eb3 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 @@ -35,6 +35,7 @@ import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '. import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component'; interface SortOption { value: string; @@ -429,6 +430,10 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { } installItem(item: MpItemVersionView): void { + if (item.type === ItemType.DEVICE) { + this.installDevice(item); + return; + } const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { panelClass: ['tb-dialog'], autoFocus: false, @@ -444,6 +449,25 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { }); } + private installDevice(item: MpItemVersionView): void { + this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).subscribe({ + next: async (blob: Blob) => { + const zipData = await blob.arrayBuffer(); + const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: true, + autoFocus: false, + data: { item, zipData, iotHubApiService: this.iotHubApiService } as DeviceInstallDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.loadItems(); + } + }); + } + }); + } + updateItem(item: MpItemVersionView): void { const installedItem = this.getInstalledItem(item); if (!installedItem) { diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts index 74f9a58302..2eef023df1 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -28,6 +28,7 @@ import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '. import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component'; interface CategoryCard { type: ItemType; @@ -232,6 +233,10 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } installItem(item: MpItemVersionView): void { + if (item.type === ItemType.DEVICE) { + this.installDevice(item); + return; + } const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { panelClass: ['tb-dialog'], autoFocus: false, @@ -247,6 +252,25 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { }); } + private installDevice(item: MpItemVersionView): void { + this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).subscribe({ + next: async (blob: Blob) => { + const zipData = await blob.arrayBuffer(); + const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: true, + autoFocus: false, + data: { item, zipData, iotHubApiService: this.iotHubApiService } as DeviceInstallDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.reloadInstalledItems(item.type); + } + }); + } + }); + } + updateItem(item: MpItemVersionView): void { const installedItem = this.findInstalledItem(item); if (!installedItem) { 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 a8401a2313..e8a875acea 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 @@ -170,6 +170,7 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { case 'DASHBOARD': return descriptor.dashboardId?.id; case 'CALCULATED_FIELD': return descriptor.entityId?.id; case 'RULE_CHAIN': return descriptor.ruleChainId?.id; + case 'DEVICE': return descriptor.dashboardId?.id ?? null; case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id; default: return null; } @@ -182,6 +183,7 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { case 'DASHBOARD': return EntityType.DASHBOARD; case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType; case 'RULE_CHAIN': return EntityType.RULE_CHAIN; + case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null; case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD; default: return null; } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts index baef37c91a..5cfe0b3992 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -27,6 +27,7 @@ import { getEntityDetailsPageURL } from '@core/utils'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component'; import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; @@ -239,6 +240,10 @@ export class TbIotHubItemDetailDialogComponent { } install(): void { + if (this.item.type === ItemType.DEVICE) { + this.installDevice(); + return; + } const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { panelClass: ['tb-dialog'], data: { @@ -253,6 +258,30 @@ export class TbIotHubItemDetailDialogComponent { }); } + private installDevice(): void { + const versionId = this.item.id as string; + this.data.iotHubApiService.getVersionFileData(versionId, { ignoreLoading: true }).subscribe({ + next: async (blob: Blob) => { + const zipData = await blob.arrayBuffer(); + const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: true, + autoFocus: false, + data: { + item: this.item, + zipData, + iotHubApiService: this.data.iotHubApiService + } as DeviceInstallDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.dialogRef.close('installed'); + } + }); + } + }); + } + updateItem(): void { const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { panelClass: ['tb-dialog'], diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts index 6af0e33d0e..9c0f66a7bd 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts @@ -28,6 +28,7 @@ import { TbIotHubItemCardComponent } from './iot-hub-item-card.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; @NgModule({ declarations: [ @@ -40,7 +41,8 @@ import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component TbIotHubItemCardComponent, TbIotHubInstalledItemsComponent, TbIotHubUpdateDialogComponent, - TbIotHubDeleteDialogComponent + TbIotHubDeleteDialogComponent, + TbDeviceInstallDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts new file mode 100644 index 0000000000..38cfe637a4 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts @@ -0,0 +1,134 @@ +/// +/// 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. +/// + +export enum ConnectivityType { + DIRECT_HTTP = 'DIRECT_HTTP', + DIRECT_MQTT = 'DIRECT_MQTT', + DIRECT_COAP = 'DIRECT_COAP', + DIRECT_LWM2M = 'DIRECT_LWM2M', + DIRECT_SNMP = 'DIRECT_SNMP', + GATEWAY_MQTT = 'GATEWAY_MQTT', + GATEWAY_MODBUS = 'GATEWAY_MODBUS', + GATEWAY_OPCUA = 'GATEWAY_OPCUA', + CHIRPSTACK = 'CHIRPSTACK', + INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', + INTEGRATION_TTN = 'INTEGRATION_TTN', + INTEGRATION_LORIOT = 'INTEGRATION_LORIOT' +} + +export const connectivityTypeTranslations = new Map( + [ + [ConnectivityType.DIRECT_HTTP, 'HTTP'], + [ConnectivityType.DIRECT_MQTT, 'MQTT'], + [ConnectivityType.DIRECT_COAP, 'CoAP'], + [ConnectivityType.DIRECT_LWM2M, 'LwM2M'], + [ConnectivityType.DIRECT_SNMP, 'SNMP'], + [ConnectivityType.GATEWAY_MQTT, 'MQTT Gateway'], + [ConnectivityType.GATEWAY_MODBUS, 'Modbus Gateway'], + [ConnectivityType.GATEWAY_OPCUA, 'OPC-UA Gateway'], + [ConnectivityType.CHIRPSTACK, 'ChirpStack'], + [ConnectivityType.INTEGRATION_CHIRPSTACK, 'ChirpStack (PE)'], + [ConnectivityType.INTEGRATION_TTN, 'The Things Stack'], + [ConnectivityType.INTEGRATION_LORIOT, 'LORIOT'] + ] +); + +export enum InstallStepType { + SHOW_INSTRUCTION = 'SHOW_INSTRUCTION', + SHOW_FORM = 'SHOW_FORM', + DEVICE_PROFILE = 'DEVICE_PROFILE', + CONVERTER = 'CONVERTER', + INTEGRATION = 'INTEGRATION', + DEVICE = 'DEVICE', + DASHBOARD = 'DASHBOARD', + RULE_CHAIN = 'RULE_CHAIN' +} + +export const ENTITY_STEP_TYPES: Set = new Set([ + InstallStepType.DEVICE_PROFILE, + InstallStepType.DEVICE, + InstallStepType.DASHBOARD, + InstallStepType.RULE_CHAIN +]); + +export const stepTypeAliasMap: Record = { + [InstallStepType.DEVICE_PROFILE]: 'deviceProfile', + [InstallStepType.DEVICE]: 'device', + [InstallStepType.DASHBOARD]: 'dashboard', + [InstallStepType.RULE_CHAIN]: 'ruleChain' +}; + +export interface DeviceInstallStep { + type: InstallStepType; + name: string; + file?: string; + template?: string; +} + +export interface DevicePackageInfo { + name: string; + description: string; + vendor: string; + hardwareType: string; + connectivityTypes: string[]; + installSteps: Record; +} + +export enum FormFieldType { + STRING = 'STRING', + INTEGER = 'INTEGER', + BOOLEAN = 'BOOLEAN', + SELECT = 'SELECT', + PASSWORD = 'PASSWORD' +} + +export interface FormFieldValidator { + pattern: string; + message: string; +} + +export interface FormFieldOption { + value: string; + label: string; +} + +export interface FormFieldDefinition { + key: string; + label: string; + type: FormFieldType; + defaultValue?: any; + required?: boolean; + helpText?: string; + helpImage?: string; + validators?: FormFieldValidator[]; + options?: FormFieldOption[]; +} + +export interface EntityStepOutput { + id: string; + name: string; + token?: string; +} + +export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error'; + +export interface EntityStepProgress { + step: DeviceInstallStep; + status: EntityStepStatus; + resolvedName?: string; + entityOutput?: EntityStepOutput; + errorMessage?: string; +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts index fe864cbb06..c2dff3af75 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -39,6 +39,8 @@ export interface RuleChainInstalledItemDescriptor { export interface DeviceInstalledItemDescriptor { type: 'DEVICE'; + createdEntityIds?: { entityType: string; id: string }[]; + dashboardId?: { id: string }; } export interface SolutionTemplateInstalledItemDescriptor { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 082e5391d5..409cf75b30 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -82,7 +82,8 @@ "upload": "Upload", "delete-anyway": "Delete anyway", "delete-selected": "Delete selected", - "set": "Set" + "set": "Set", + "retry": "Retry" }, "aggregation": { "aggregation": "Aggregation", @@ -3864,7 +3865,22 @@ "open-installed-entity": "Open installed entity", "view-item-details": "View item details", "open-item-type-details": "Open {{type}} details", - "back-to-marketplace": "Back to Marketplace" + "back-to-marketplace": "Back to Marketplace", + "device-install-connection": "Connection: {{type}}", + "device-install-entity-error": "Failed to create {{name}}: {{error}}", + "device-install-expand-image": "Click to expand", + "device-install-select-connectivity": "Select your connection type:", + "device-install-step-error": "Failed", + "device-install-step-pending": "Pending", + "device-install-step-running": "Creating...", + "device-install-step-success": "Created", + "device-install-step-type-DASHBOARD": "Dashboard", + "device-install-step-type-DEVICE": "Device", + "device-install-step-type-DEVICE_PROFILE": "Device Profile", + "device-install-step-type-RULE_CHAIN": "Rule Chain", + "device-install-title": "Install \"{{name}}\"", + "device-install-complete-title": "Install \"{{name}}\" — Complete", + "device-installing-title": "Installing \"{{name}}\"" }, "item-data": { "widget-type-timeseries": "Timeseries",