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; package org.thingsboard.server.common.data.iot_hub;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import java.util.List; import java.util.List;
import java.util.Map;
@Data @Data
public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor { public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor {
private List<EntityId> createdEntityIds; private List<EntityId> createdEntityIds;
private DashboardId dashboardId; 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()).hasSize(1);
assertThat(deserialized.getCreatedEntityIds().get(0).getId()).isEqualTo(dashboardId.getId()); 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( public registerDeviceInstall(
versionId: string, 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 config?: IotHubRequestConfig
): Observable<InstallItemVersionResult> { ): Observable<InstallItemVersionResult> {
return this.http.post<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> </ng-template>
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) { @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" [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> <ng-template matStepLabel>{{ ws.label }}</ng-template>
<div class="tb-wizard-step-content"> <div class="tb-wizard-step-content">
@ -200,7 +200,9 @@
</div> </div>
<mat-dialog-actions align="end"> <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) { @switch (step.type) {
@case ('instruction') { @case ('instruction') {
@if (isLastWizardStep) { @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 { export interface DeviceInstallDialogData {
item: MpItemVersionView; item: MpItemVersionView;
zipData: ArrayBuffer; zipData: ArrayBuffer;
reviewMode?: boolean;
selectedConnectivity?: string;
installState?: Record<string, any>;
} }
export type WizardStepType = 'instruction' | 'form' | 'progress'; export type WizardStepType = 'instruction' | 'form' | 'progress';
@ -96,6 +99,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
wizardSteps: WizardStep[] = []; wizardSteps: WizardStep[] = [];
wizardStarted = false; wizardStarted = false;
passwordVisible: Record<string, boolean> = {}; passwordVisible: Record<string, boolean> = {};
reviewMode = false;
// Variable resolution state // Variable resolution state
formValues: Record<string, any> = {}; formValues: Record<string, any> = {};
@ -165,6 +169,14 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
} catch (_e) { } catch (_e) {
console.error('Failed to fetch connectivity settings', _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.loading = false;
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
@ -368,9 +380,38 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
// --- Private --- // --- 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 { private startWizard(): void {
this.formValues = {}; if (!this.reviewMode) {
this.entityOutputs.clear(); this.formValues = {};
this.entityOutputs.clear();
}
this.passwordVisible = {}; this.passwordVisible = {};
this.buildWizardSteps(); this.buildWizardSteps();
this.wizardStarted = true; this.wizardStarted = true;
@ -390,7 +431,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
type: 'instruction', type: 'instruction',
label: step.name, label: step.name,
rawSteps: [step], rawSteps: [step],
completed: false completed: this.reviewMode
}); });
i++; i++;
} else if (step.type === InstallStepType.SHOW_FORM) { } else if (step.type === InstallStepType.SHOW_FORM) {
@ -398,7 +439,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
type: 'form', type: 'form',
label: step.name, label: step.name,
rawSteps: [step], rawSteps: [step],
completed: false completed: this.reviewMode
}); });
i++; i++;
} else if (ENTITY_STEP_TYPES.has(step.type)) { } else if (ENTITY_STEP_TYPES.has(step.type)) {
@ -412,7 +453,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
type: 'progress', type: 'progress',
label: 'Provisioning', label: 'Provisioning',
rawSteps: group, rawSteps: group,
completed: false completed: this.reviewMode
}); });
} else { } else {
// Skip unsupported steps (CONVERTER, INTEGRATION) // Skip unsupported steps (CONVERTER, INTEGRATION)
@ -431,6 +472,8 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]'; const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]';
ws.formFields = JSON.parse(formJson) as FormFieldDefinition[]; ws.formFields = JSON.parse(formJson) as FormFieldDefinition[];
const controls: Record<string, UntypedFormControl> = {}; 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) { for (const field of ws.formFields) {
const validators = []; const validators = [];
if (field.required) { if (field.required) {
@ -439,13 +482,17 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (field.validators?.length > 0) { if (field.validators?.length > 0) {
validators.push(Validators.pattern(field.validators[0].pattern)); 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); controls[field.key] = new UntypedFormControl(initialValue, validators);
if (field.type === FormFieldType.PASSWORD) { 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); ws.formGroup = new UntypedFormGroup(controls);
if (this.reviewMode) {
ws.formGroup.disable();
ws.completed = true;
}
} }
private onStepActivated(): void { private onStepActivated(): void {
@ -457,10 +504,29 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
const raw = this.zipFiles.get(step.rawSteps[0].file) || ''; const raw = this.zipFiles.get(step.rawSteps[0].file) || '';
step.markdown = this.resolveImages(this.resolveVariables(raw)); step.markdown = this.resolveImages(this.resolveVariables(raw));
} else if (step.type === 'progress' && !step.progressDone) { } 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 { private initAndRunEntitySteps(ws: WizardStep): void {
ws.entitySteps = ws.rawSteps.map(s => ({ ws.entitySteps = ws.rawSteps.map(s => ({
step: s, step: s,
@ -522,7 +588,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
await firstValueFrom( await firstValueFrom(
this.iotHubApiService.registerDeviceInstall( this.iotHubApiService.registerDeviceInstall(
this.data.item.id as string, this.data.item.id as string,
{ createdEntityIds, dashboardId }, { createdEntityIds, dashboardId, selectedConnectivity: this.selectedConnectivity, installState: this.buildInstallState() },
{ ignoreLoading: true } { 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; 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 }[] { private collectCreatedEntityIds(): { entityType: string; id: string }[] {
const ids: { entityType: string; id: string }[] = []; const ids: { entityType: string; id: string }[] = [];
for (const ws of this.wizardSteps) { 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 { 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 { 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 { 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({ @Component({
selector: 'tb-iot-hub-installed-items', selector: 'tb-iot-hub-installed-items',
@ -229,6 +231,10 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O
} }
viewItemDetails(item: IotHubInstalledItem): void { viewItemDetails(item: IotHubInstalledItem): void {
if (item.itemType === 'DEVICE') {
this.openDeviceReviewDialog(item);
return;
}
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => {
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], 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 { getEntityId(item: IotHubInstalledItem): string | null {
const descriptor = item.descriptor; const descriptor = item.descriptor;
switch (descriptor.type) { 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'; type: 'DEVICE';
createdEntityIds?: { entityType: string; id: string }[]; createdEntityIds?: { entityType: string; id: string }[];
dashboardId?: { id: string }; dashboardId?: { id: string };
selectedConnectivity?: string;
installState?: Record<string, any>;
} }
export interface SolutionTemplateInstalledItemDescriptor { export interface SolutionTemplateInstalledItemDescriptor {

Loading…
Cancel
Save