From e2020bc9dad314bf3df56e239a51a27a8daa3180 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Wed, 1 Apr 2026 16:38:02 +0300 Subject: [PATCH] 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) --- .../device-install-dialog.component.html | 217 +++++++++++ .../device-install-dialog.component.scss | 210 ++++++++++ .../device-install-dialog.component.ts | 368 ++++++++++++++++++ .../home/pages/iot-hub/iot-hub.module.ts | 4 +- 4 files changed, 798 insertions(+), 1 deletion(-) 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 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..17bc7a3f91 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -0,0 +1,217 @@ + +@if (loading) { +
+ +
+} @else { +
+

{{ packageInfo?.name || data.item.name }}

+ @if (selectedConnectivity) { + {{ getConnectivityLabel(selectedConnectivity) }} + } + +
+ +
+ @switch (currentView) { + @case ('connectivity') { +
+

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

+
+ @for (ct of availableConnectivityTypes; track ct) { + + } +
+
+ } + @case ('instruction') { +
+ + +
+ } + @case ('form') { +
+ @if (currentFormGroup) { +
+ @for (field of currentFormFields; 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 (currentFormGroup.controls[field.key]?.hasError('required')) { + {{ 'iot-hub.device-install.field-required' | translate }} + } + + } + @case ('PASSWORD') { + + {{ field.label }} + + + @if (currentFormGroup.controls[field.key]?.hasError('required')) { + {{ 'iot-hub.device-install.field-required' | translate }} + } + @if (currentFormGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @case ('INTEGER') { + + {{ field.label }} + + @if (currentFormGroup.controls[field.key]?.hasError('required')) { + {{ 'iot-hub.device-install.field-required' | translate }} + } + @if (currentFormGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @default { + + {{ field.label }} + + @if (currentFormGroup.controls[field.key]?.hasError('required')) { + {{ 'iot-hub.device-install.field-required' | translate }} + } + @if (currentFormGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + } + @if (field.helpText) { +
{{ field.helpText }}
+ } + @if (field.helpImage) { + + } + } +
+ } +
+ } + @case ('progress') { +
+ @for (ep of entitySteps; track ep.step.name) { +
+
+ @switch (ep.status) { + @case ('pending') { + radio_button_unchecked + } + @case ('running') { + + } + @case ('success') { + check_circle + } + @case ('error') { + error + } + } +
+
+
{{ ep.resolvedName || ep.step.name }}
+ @if (ep.status === 'error' && ep.errorMessage) { +
{{ ep.errorMessage }}
+ } +
+
+ } +
+ @if (progressError) { +
{{ progressError }}
+ } + } + @case ('done') { +
+ @if (currentMarkdown) { + + + } @else { +

{{ 'iot-hub.device-install.success' | translate:{ name: packageInfo?.name } }}

+ } +
+ } + } +
+ + @switch (currentView) { + @case ('connectivity') { + + + } + @case ('instruction') { + + + } + @case ('form') { + + + } + @case ('progress') { + + @if (progressError) { + + } + } + @case ('done') { + + } + } + +} 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..01f4428e85 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -0,0 +1,210 @@ +/** + * 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: 640px; + max-width: 90vw; + max-height: 80vh; +} + +.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-body { + flex: 1; + overflow-y: auto; + padding: 0 24px 24px; + min-height: 200px; +} + +.tb-device-install-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 0; +} + +.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; + } +} + +.tb-device-install-instruction { + ::ng-deep tb-markdown { + display: block; + } +} + +.tb-device-install-form { + display: flex; + flex-direction: column; + gap: 16px; + + .tb-form-field-help { + color: rgba(0, 0, 0, 0.54); + font-size: 12px; + line-height: 16px; + margin-top: 4px; + } + + .tb-form-field-help-image { + max-width: 100%; + margin-top: 8px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + } +} + +.tb-device-install-progress { + display: flex; + flex-direction: column; + gap: 12px; + + .tb-progress-row { + display: flex; + align-items: center; + 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-label { + flex: 1; + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.87); + } + + .tb-progress-error-msg { + font-size: 12px; + color: #c62828; + margin-top: 4px; + } + } +} + +.tb-device-install-done { + display: flex; + flex-direction: column; + gap: 16px; + + .tb-done-message { + color: rgba(0, 0, 0, 0.87); + font-size: 14px; + line-height: 20px; + } +} + +.tb-device-install-error { + max-height: 200px; + overflow-y: auto; + padding: 12px; + background: #f5f5f5; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + color: rgba(0, 0, 0, 0.7); + white-space: pre-wrap; + word-break: break-word; +} + +mat-dialog-actions { + padding: 8px 24px 16px; +} + +.tb-iot-hub-inline-spinner { + display: inline-block; + margin-right: 8px; +} 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..4db526fbe4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -0,0 +1,368 @@ +/// +/// 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 { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +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 { + ConnectivityType, + connectivityTypeTranslations, + DeviceInstallStep, + DevicePackageInfo, + ENTITY_STEP_TYPES, + EntityStepOutput, + EntityStepProgress, + FormFieldDefinition, + FormFieldType, + InstallStepType, + stepTypeAliasMap +} from '@shared/models/iot-hub/device-package.models'; + +export type DeviceInstallView = 'connectivity' | 'instruction' | 'form' | 'progress' | 'done'; + +export interface DeviceInstallDialogData { + item: MpItemVersionView; + zipData: ArrayBuffer; + iotHubApiService: IotHubApiService; +} + +@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 { + + loading = true; + packageInfo: DevicePackageInfo; + zipFiles = new Map(); + + availableConnectivityTypes: string[] = []; + selectedConnectivity: string | null = null; + + currentView: DeviceInstallView = 'connectivity'; + steps: DeviceInstallStep[] = []; + currentStepIndex = 0; + + currentMarkdown = ''; + currentFormFields: FormFieldDefinition[] = []; + currentFormGroup: UntypedFormGroup | null = null; + passwordVisible: Record = {}; + + formValues: Record = {}; + entityOutputs = new Map(); + positionalOutputs = new Map(); + + entitySteps: EntityStepProgress[] = []; + progressError: string | null = null; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData, + private dialogRef: MatDialogRef, + 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)) { + 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]; + } + } catch (e) { + console.error('Failed to parse device package ZIP', e); + } + this.loading = false; + } + + getConnectivityLabel(ct: string): string { + return connectivityTypeTranslations.get(ct) || ct; + } + + selectConnectivity(ct: string): void { + this.selectedConnectivity = ct; + } + + next(): void { + if (this.currentView === 'connectivity') { + if (!this.selectedConnectivity) { + return; + } + this.steps = this.packageInfo.installSteps[this.selectedConnectivity] || []; + this.currentStepIndex = 0; + this.formValues = {}; + this.entityOutputs.clear(); + this.positionalOutputs.clear(); + this.advanceToCurrentStep(); + return; + } + + if (this.currentView === 'form') { + if (this.currentFormGroup && this.currentFormGroup.invalid) { + this.currentFormGroup.markAllAsTouched(); + return; + } + if (this.currentFormGroup) { + Object.assign(this.formValues, this.currentFormGroup.value); + } + } + + if (this.currentView === 'instruction' && this.isLastStep()) { + this.done(); + return; + } + + this.currentStepIndex++; + this.advanceToCurrentStep(); + } + + advanceToCurrentStep(): void { + if (this.currentStepIndex >= this.steps.length) { + this.currentView = 'done'; + this.currentMarkdown = ''; + return; + } + + const step = this.steps[this.currentStepIndex]; + + switch (step.type) { + case InstallStepType.SHOW_INSTRUCTION: { + this.currentView = 'instruction'; + const raw = this.zipFiles.get(step.file) || ''; + this.currentMarkdown = this.resolveVariables(raw); + break; + } + case InstallStepType.SHOW_FORM: { + this.currentView = 'form'; + const formJson = this.zipFiles.get(step.file) || '[]'; + this.currentFormFields = JSON.parse(formJson) as FormFieldDefinition[]; + this.buildFormGroup(); + break; + } + default: { + if (ENTITY_STEP_TYPES.has(step.type)) { + this.collectAndRunEntitySteps(); + } else { + // Skip unsupported step types (CONVERTER, INTEGRATION) + this.currentStepIndex++; + this.advanceToCurrentStep(); + } + break; + } + } + } + + isLastStep(): boolean { + return this.currentStepIndex >= this.steps.length - 1; + } + + done(): void { + this.dialogRef.close('installed'); + } + + cancel(): void { + this.dialogRef.close(false); + } + + retryEntitySteps(): void { + this.progressError = null; + this.runEntitySteps(); + } + + resolveImageUrl(path: string): string { + return this.data.iotHubApiService.resolveResourceUrl(path); + } + + getPatternErrorMessage(field: FormFieldDefinition): string { + if (field.validators?.length > 0) { + return field.validators[0].message || 'Invalid format'; + } + return 'Invalid format'; + } + + 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[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[prop]); + } + } + } + return '${' + key + '}'; + }); + } + + private buildFormGroup(): void { + const controls: Record = {}; + this.passwordVisible = {}; + for (const field of this.currentFormFields) { + 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.key in this.formValues + ? this.formValues[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.currentFormGroup = new UntypedFormGroup(controls); + } + + private collectAndRunEntitySteps(): void { + this.entitySteps = []; + let i = this.currentStepIndex; + while (i < this.steps.length && ENTITY_STEP_TYPES.has(this.steps[i].type)) { + this.entitySteps.push({ + step: this.steps[i], + status: 'pending', + resolvedName: this.resolveVariables(this.steps[i].name) + }); + i++; + } + this.currentView = 'progress'; + this.progressError = null; + this.runEntitySteps(); + } + + private async runEntitySteps(): Promise { + for (const ep of this.entitySteps) { + if (ep.status === 'success') { + continue; + } + ep.status = 'running'; + ep.errorMessage = null; + try { + const output = await this.createEntity(ep.step); + ep.entityOutput = output; + ep.status = 'success'; + + const alias = stepTypeAliasMap[ep.step.type]; + if (alias) { + this.entityOutputs.set(alias, output); + } + const stepIdx = this.steps.indexOf(ep.step); + if (stepIdx >= 0) { + this.positionalOutputs.set(stepIdx + 1, output); + } + + // Re-resolve names of remaining pending steps + for (const remaining of this.entitySteps) { + if (remaining.status === 'pending') { + remaining.resolvedName = this.resolveVariables(remaining.step.name); + } + } + } catch (err: any) { + ep.status = 'error'; + ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; + this.progressError = ep.errorMessage; + return; + } + } + + // All entity steps succeeded — advance past them + const lastEntityIdx = this.currentStepIndex + this.entitySteps.length - 1; + this.currentStepIndex = lastEntityIdx + 1; + + // Report install + try { + await firstValueFrom( + this.data.iotHubApiService.reportVersionInstalled(this.data.item.id as string, { ignoreLoading: true }) + ); + } catch (_e) { + // Non-critical — best effort + } + + // Advance to next step (instruction after entities, or done) + this.advanceToCurrentStep(); + } + + 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 result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template)); + return { id: result.id.id, name: result.name }; + } + case InstallStepType.DEVICE: { + const result = await firstValueFrom(this.deviceService.saveDevice(template)); + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id)); + return { id: result.id.id, name: result.name, token: creds.credentialsId }; + } + case InstallStepType.DASHBOARD: { + const result = await firstValueFrom(this.dashboardService.saveDashboard(template)); + return { id: result.id.id, name: result.title }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain)); + if (metadata) { + metadata.ruleChainId = saved.id; + await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata)); + } + return { id: saved.id.id, name: saved.name }; + } + default: + throw new Error(`Unsupported entity step type: ${step.type}`); + } + } +} 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 f7dae518bc..564c0aa6d8 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 @@ -27,6 +27,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: [ @@ -38,7 +39,8 @@ import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component TbIotHubItemCardComponent, TbIotHubInstalledItemsComponent, TbIotHubUpdateDialogComponent, - TbIotHubDeleteDialogComponent + TbIotHubDeleteDialogComponent, + TbDeviceInstallDialogComponent ], imports: [ CommonModule,