Browse Source

feat(iot-hub): add read-only install review dialog for device packages

Store install state (form values, entity outputs, selected connectivity)
in DeviceInstalledItemDescriptor during registration. Installed Items
info icon for DEVICE type re-fetches the ZIP and opens the wizard in
read-only review mode:

- All stepper steps clickable (jump to any step)
- Form fields populated with stored values and disabled
- Entity progress shows all items as completed
- Markdown instructions rendered with resolved variables
- Download buttons and image galleries still work
- Single "Close" button in footer

Backend: add selectedConnectivity and installState fields to descriptor
Test: verify installState deserialization round-trip
pull/15508/head
Andrii Shvaika 6 months ago
parent
commit
a01513a876
  1. 4
      common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java
  2. 54
      common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java
  3. 2
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  4. 8
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html
  5. 100
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts
  6. 33
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts
  7. 2
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts

4
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<EntityId> createdEntityIds;
private DashboardId dashboardId;
private String selectedConnectivity;
private Map<String, JsonNode> installState;
}

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

2
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<string, any> },
config?: IotHubRequestConfig
): Observable<InstallItemVersionResult> {
return this.http.post<InstallItemVersionResult>(

8
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html

@ -67,9 +67,9 @@
</ng-template>
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) {
<mat-step [editable]="ws.type === 'form'"
<mat-step [editable]="reviewMode || ws.type === 'form'"
[completed]="ws.completed"
[stepControl]="ws.type === 'form' ? ws.formGroup : null">
[stepControl]="ws.type === 'form' && !reviewMode ? ws.formGroup : null">
<ng-template matStepLabel>{{ ws.label }}</ng-template>
<div class="tb-wizard-step-content">
@ -200,7 +200,9 @@
</div>
<mat-dialog-actions align="end">
@if (currentWizardStep; as step) {
@if (reviewMode) {
<button mat-flat-button color="primary" (click)="done()">{{ 'action.close' | translate }}</button>
} @else if (currentWizardStep; as step) {
@switch (step.type) {
@case ('instruction') {
@if (isLastWizardStep) {

100
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<string, any>;
}
export type WizardStepType = 'instruction' | 'form' | 'progress';
@ -96,6 +99,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
wizardSteps: WizardStep[] = [];
wizardStarted = false;
passwordVisible: Record<string, boolean> = {};
reviewMode = false;
// Variable resolution state
formValues: Record<string, any> = {};
@ -165,6 +169,14 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
} catch (_e) {
console.error('Failed to fetch connectivity settings', _e);
}
// Review mode: restore state from stored installState
if (this.data.reviewMode && this.data.installState && this.data.selectedConnectivity) {
this.reviewMode = true;
this.selectedConnectivity = this.data.selectedConnectivity;
this.showConnectivitySelector = false;
this.restoreInstallState(this.data.installState);
this.startWizard();
}
this.loading = false;
this.cdr.detectChanges();
}
@ -368,9 +380,38 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
// --- Private ---
private restoreInstallState(installState: Record<string, any>): 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<TbDeviceInst
type: 'instruction',
label: step.name,
rawSteps: [step],
completed: false
completed: this.reviewMode
});
i++;
} else if (step.type === InstallStepType.SHOW_FORM) {
@ -398,7 +439,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
type: 'form',
label: step.name,
rawSteps: [step],
completed: false
completed: this.reviewMode
});
i++;
} else if (ENTITY_STEP_TYPES.has(step.type)) {
@ -412,7 +453,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
type: 'progress',
label: 'Provisioning',
rawSteps: group,
completed: false
completed: this.reviewMode
});
} else {
// Skip unsupported steps (CONVERTER, INTEGRATION)
@ -431,6 +472,8 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]';
ws.formFields = JSON.parse(formJson) as FormFieldDefinition[];
const controls: Record<string, UntypedFormControl> = {};
// 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<TbDeviceInst
if (field.validators?.length > 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<TbDeviceInst
const raw = this.zipFiles.get(step.rawSteps[0].file) || '';
step.markdown = this.resolveImages(this.resolveVariables(raw));
} else if (step.type === 'progress' && !step.progressDone) {
this.initAndRunEntitySteps(step);
if (this.reviewMode) {
this.showCompletedEntitySteps(step);
} else {
this.initAndRunEntitySteps(step);
}
}
}
private showCompletedEntitySteps(ws: WizardStep): void {
ws.entitySteps = ws.rawSteps.map(s => {
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<TbDeviceInst
await firstValueFrom(
this.iotHubApiService.registerDeviceInstall(
this.data.item.id as string,
{ createdEntityIds, dashboardId },
{ createdEntityIds, dashboardId, selectedConnectivity: this.selectedConnectivity, installState: this.buildInstallState() },
{ ignoreLoading: true }
)
);
@ -678,6 +744,22 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
return match ? { id: match.id.id, name: match.name, url: `/ruleChains/${match.id.id}` } : null;
}
private buildInstallState(): Record<string, any> {
const state: Record<string, any> = {};
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) {

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

2
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<string, any>;
}
export interface SolutionTemplateInstalledItemDescriptor {

Loading…
Cancel
Save