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 6b345f9e59..b3bb9b7e14 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 @@ -15,16 +15,20 @@ */ package org.thingsboard.server.common.data.iot_hub; +import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EntityId; import java.util.List; +import java.util.Map; @Data public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor { private List createdEntityIds; private DashboardId dashboardId; + private String selectedConnectivity; + private Map installState; } 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 index f427f17b79..0787156fc4 100644 --- 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 @@ -72,4 +72,58 @@ class DeviceInstalledItemDescriptorTest { assertThat(deserialized.getCreatedEntityIds()).hasSize(1); assertThat(deserialized.getCreatedEntityIds().get(0).getId()).isEqualTo(dashboardId.getId()); } + + @Test + void deserializeWithInstallState() throws Exception { + 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"}, + "selectedConnectivity": "DIRECT_MQTT", + "installState": { + "Configuration": { + "formValues": {"deviceName": "ESP32 Dev Kit", "wifiSsid": "MyWiFi", "wifiPassword": "secret"} + }, + "ESP32 Dev Kit": { + "entityOutput": {"id": "8a90d5e0-2e5d-11f1-a802-c35a9af2ebde", "name": "ESP32 Dev Kit", "token": "abc123", "url": "/entities/devices/8a90d5e0-2e5d-11f1-a802-c35a9af2ebde"} + }, + "ESP32 Monitor": { + "entityOutput": {"id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde", "name": "ESP32 Monitor", "url": "/dashboards/8bc229f0-2e5d-11f1-a802-c35a9af2ebde"} + } + } + } + """; + + JsonNode node = mapper.readTree(json); + DeviceInstalledItemDescriptor descriptor = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.getSelectedConnectivity()).isEqualTo("DIRECT_MQTT"); + assertThat(descriptor.getInstallState()).isNotNull(); + assertThat(descriptor.getInstallState()).hasSize(3); + + // Verify form values + JsonNode configState = descriptor.getInstallState().get("Configuration"); + assertThat(configState).isNotNull(); + assertThat(configState.get("formValues").get("deviceName").asText()).isEqualTo("ESP32 Dev Kit"); + assertThat(configState.get("formValues").get("wifiSsid").asText()).isEqualTo("MyWiFi"); + + // Verify entity output + JsonNode deviceState = descriptor.getInstallState().get("ESP32 Dev Kit"); + assertThat(deviceState).isNotNull(); + assertThat(deviceState.get("entityOutput").get("token").asText()).isEqualTo("abc123"); + + // Verify round-trip + String serialized = mapper.writeValueAsString(descriptor); + JsonNode roundTripped = mapper.readTree(serialized); + DeviceInstalledItemDescriptor deserialized = mapper.treeToValue(roundTripped, DeviceInstalledItemDescriptor.class); + assertThat(deserialized.getSelectedConnectivity()).isEqualTo("DIRECT_MQTT"); + assertThat(deserialized.getInstallState()).hasSize(3); + assertThat(deserialized.getInstallState().get("Configuration").get("formValues").get("deviceName").asText()).isEqualTo("ESP32 Dev Kit"); + } } 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 c63e03405e..c9a2c053bb 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 @@ -122,7 +122,7 @@ export class IotHubApiService { public registerDeviceInstall( versionId: string, - descriptor: { type?: string; createdEntityIds: { entityType: string; id: string }[]; dashboardId?: { entityType: string; id: string } }, + descriptor: { type?: string; createdEntityIds: { entityType: string; id: string }[]; dashboardId?: { entityType: string; id: string }; selectedConnectivity?: string; installState?: Record }, config?: IotHubRequestConfig ): Observable { return this.http.post( diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html index b9640a7ef5..fb463c010e 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -67,9 +67,9 @@ @for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) { - + [stepControl]="ws.type === 'form' && !reviewMode ? ws.formGroup : null"> {{ ws.label }}
@@ -200,7 +200,9 @@
- @if (currentWizardStep; as step) { + @if (reviewMode) { + + } @else if (currentWizardStep; as step) { @switch (step.type) { @case ('instruction') { @if (isLastWizardStep) { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts index 5b643b1753..95b12a7581 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -49,6 +49,9 @@ import { export interface DeviceInstallDialogData { item: MpItemVersionView; zipData: ArrayBuffer; + reviewMode?: boolean; + selectedConnectivity?: string; + installState?: Record; } export type WizardStepType = 'instruction' | 'form' | 'progress'; @@ -96,6 +99,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent = {}; + reviewMode = false; // Variable resolution state formValues: Record = {}; @@ -165,6 +169,14 @@ export class TbDeviceInstallDialogComponent extends DialogComponent): void { + for (const [stepName, data] of Object.entries(installState)) { + if (data.formValues) { + Object.assign(this.formValues, data.formValues); + } + if (data.entityOutput) { + // Find the step type alias from the raw steps to set the correct entity output key + const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || []; + const matchingStep = rawSteps.find(s => s.name === stepName || this.resolveVariables(s.name) === stepName); + if (matchingStep) { + const alias = stepTypeAliasMap[matchingStep.type]; + if (alias) { + this.entityOutputs.set(alias, data.entityOutput); + } + } + } + } + // Resolve gateway docker-compose if available + const gatewayStep = (this.packageInfo.installSteps[this.selectedConnectivity] || []).find(s => s.type === 'GATEWAY'); + if (gatewayStep?.dockerCompose) { + const raw = this.zipFiles.get(gatewayStep.dockerCompose); + if (raw) { + this.gatewayDockerComposeContent = this.resolveVariables(raw); + } + } + } + private startWizard(): void { - this.formValues = {}; - this.entityOutputs.clear(); + if (!this.reviewMode) { + this.formValues = {}; + this.entityOutputs.clear(); + } this.passwordVisible = {}; this.buildWizardSteps(); this.wizardStarted = true; @@ -390,7 +431,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent = {}; + // In review mode, use stored values; otherwise use defaults + const storedValues = this.reviewMode && this.data.installState?.[ws.label]?.formValues; for (const field of ws.formFields) { const validators = []; if (field.required) { @@ -439,13 +482,17 @@ export class TbDeviceInstallDialogComponent extends DialogComponent 0) { validators.push(Validators.pattern(field.validators[0].pattern)); } - const initialValue = field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); + const initialValue = storedValues?.[field.key] ?? field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); controls[field.key] = new UntypedFormControl(initialValue, validators); if (field.type === FormFieldType.PASSWORD) { - this.passwordVisible[field.key] = false; + this.passwordVisible[field.key] = true; // Show passwords in review mode } } ws.formGroup = new UntypedFormGroup(controls); + if (this.reviewMode) { + ws.formGroup.disable(); + ws.completed = true; + } } private onStepActivated(): void { @@ -457,10 +504,29 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { + const alias = stepTypeAliasMap[s.type]; + const output = alias ? this.entityOutputs.get(alias) : null; + return { + step: s, + status: 'success' as const, + resolvedName: this.resolveVariables(s.name), + entityOutput: output || undefined + }; + }); + ws.progressDone = true; + ws.completed = true; + } + private initAndRunEntitySteps(ws: WizardStep): void { ws.entitySteps = ws.rawSteps.map(s => ({ step: s, @@ -522,7 +588,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { + const state: Record = {}; + for (const ws of this.wizardSteps) { + if (ws.type === 'form' && ws.formGroup) { + state[ws.label] = { formValues: ws.formGroup.getRawValue() }; + } else if (ws.type === 'progress' && ws.entitySteps) { + for (const ep of ws.entitySteps) { + if (ep.status === 'success' && ep.entityOutput) { + state[ep.step.name] = { entityOutput: ep.entityOutput }; + } + } + } + } + return state; + } + private collectCreatedEntityIds(): { entityType: string; id: string }[] { const ids: { entityType: string; id: string }[] = []; for (const ws of this.wizardSteps) { 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 5cb6fe57b8..6ddd8bf2ff 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 @@ -36,6 +36,8 @@ import { getEntityDetailsPageURL } from '@core/utils'; import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '@home/components/iot-hub/iot-hub-item-detail-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from '@home/components/iot-hub/iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from '@home/components/iot-hub/iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component'; +import { DeviceInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; @Component({ selector: 'tb-iot-hub-installed-items', @@ -229,6 +231,10 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O } viewItemDetails(item: IotHubInstalledItem): void { + if (item.itemType === 'DEVICE') { + this.openDeviceReviewDialog(item); + return; + } this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], @@ -246,6 +252,33 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O }); } + private openDeviceReviewDialog(item: IotHubInstalledItem): void { + const descriptor = item.descriptor as DeviceInstalledItemDescriptor; + if (!descriptor.installState || !descriptor.selectedConnectivity) { + return; // No install state stored — can't open review + } + this.iotHubApiService.getVersionFileData(item.itemVersionId, { ignoreLoading: true }).subscribe({ + next: async (blob: Blob) => { + const zipData = await blob.arrayBuffer(); + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: false, + autoFocus: false, + data: { + item: versionView, + zipData, + iotHubApiService: this.iotHubApiService, + reviewMode: true, + selectedConnectivity: descriptor.selectedConnectivity, + installState: descriptor.installState + } as DeviceInstallDialogData + }); + }); + } + }); + } + getEntityId(item: IotHubInstalledItem): string | null { const descriptor = item.descriptor; switch (descriptor.type) { 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 c2dff3af75..21eafca533 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 @@ -41,6 +41,8 @@ export interface DeviceInstalledItemDescriptor { type: 'DEVICE'; createdEntityIds?: { entityType: string; id: string }[]; dashboardId?: { id: string }; + selectedConnectivity?: string; + installState?: Record; } export interface SolutionTemplateInstalledItemDescriptor {