Browse Source

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)
pull/15347/head
Andrii Shvaika 4 months ago
parent
commit
e2020bc9da
  1. 217
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html
  2. 210
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss
  3. 368
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts
  4. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts

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

@ -0,0 +1,217 @@
<!--
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.
-->
@if (loading) {
<div class="tb-device-install-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else {
<div class="tb-device-install-header">
<h2 class="tb-device-install-title">{{ packageInfo?.name || data.item.name }}</h2>
@if (selectedConnectivity) {
<span class="tb-device-install-badge">{{ getConnectivityLabel(selectedConnectivity) }}</span>
}
<button mat-icon-button (click)="cancel()" tabindex="-1">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="tb-device-install-body">
@switch (currentView) {
@case ('connectivity') {
<div class="tb-device-install-connectivity">
<p>{{ 'iot-hub.device-install.select-connectivity' | translate }}</p>
<div class="tb-connectivity-options">
@for (ct of availableConnectivityTypes; track ct) {
<button mat-stroked-button
class="tb-connectivity-button"
[class.selected]="selectedConnectivity === ct"
(click)="selectConnectivity(ct)">
{{ getConnectivityLabel(ct) }}
</button>
}
</div>
</div>
}
@case ('instruction') {
<div class="tb-device-install-instruction">
<tb-markdown [markdownText]="currentMarkdown"
[style]="'doc'">
</tb-markdown>
</div>
}
@case ('form') {
<div class="tb-device-install-form">
@if (currentFormGroup) {
<form [formGroup]="currentFormGroup">
@for (field of currentFormFields; track field.key) {
@switch (field.type) {
@case ('BOOLEAN') {
<mat-checkbox [formControlName]="field.key">
{{ field.label }}
</mat-checkbox>
}
@case ('SELECT') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<mat-select [formControlName]="field.key">
@for (opt of field.options; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ 'iot-hub.device-install.field-required' | translate }}</mat-error>
}
</mat-form-field>
}
@case ('PASSWORD') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [type]="passwordVisible[field.key] ? 'text' : 'password'"
[formControlName]="field.key">
<button mat-icon-button matSuffix type="button"
(click)="passwordVisible[field.key] = !passwordVisible[field.key]">
<mat-icon>{{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ 'iot-hub.device-install.field-required' | translate }}</mat-error>
}
@if (currentFormGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@case ('INTEGER') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput type="number" [formControlName]="field.key">
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ 'iot-hub.device-install.field-required' | translate }}</mat-error>
}
@if (currentFormGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@default {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [formControlName]="field.key">
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ 'iot-hub.device-install.field-required' | translate }}</mat-error>
}
@if (currentFormGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
}
@if (field.helpText) {
<div class="tb-form-field-help">{{ field.helpText }}</div>
}
@if (field.helpImage) {
<img class="tb-form-field-help-image" [src]="resolveImageUrl(field.helpImage)" [alt]="field.label">
}
}
</form>
}
</div>
}
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of entitySteps; track ep.step.name) {
<div class="tb-progress-row">
<div class="tb-progress-icon">
@switch (ep.status) {
@case ('pending') {
<mat-icon class="tb-progress-pending">radio_button_unchecked</mat-icon>
}
@case ('running') {
<mat-spinner diameter="20"></mat-spinner>
}
@case ('success') {
<mat-icon class="tb-progress-success">check_circle</mat-icon>
}
@case ('error') {
<mat-icon class="tb-progress-error">error</mat-icon>
}
}
</div>
<div>
<div class="tb-progress-label">{{ ep.resolvedName || ep.step.name }}</div>
@if (ep.status === 'error' && ep.errorMessage) {
<div class="tb-progress-error-msg">{{ ep.errorMessage }}</div>
}
</div>
</div>
}
</div>
@if (progressError) {
<div class="tb-device-install-error">{{ progressError }}</div>
}
}
@case ('done') {
<div class="tb-device-install-done">
@if (currentMarkdown) {
<tb-markdown [markdownText]="currentMarkdown"
[style]="'doc'">
</tb-markdown>
} @else {
<p class="tb-done-message">{{ 'iot-hub.device-install.success' | translate:{ name: packageInfo?.name } }}</p>
}
</div>
}
}
</div>
<mat-dialog-actions align="end">
@switch (currentView) {
@case ('connectivity') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary"
[disabled]="!selectedConnectivity"
(click)="next()">
{{ 'action.next' | translate }}
</button>
}
@case ('instruction') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="next()">
{{ (isLastStep() ? 'action.done' : 'action.next') | translate }}
</button>
}
@case ('form') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="next()">
{{ 'action.next' | translate }}
</button>
}
@case ('progress') {
<button mat-button (click)="cancel()" [disabled]="!progressError">{{ 'action.cancel' | translate }}</button>
@if (progressError) {
<button mat-flat-button color="primary" (click)="retryEntitySteps()">
{{ 'action.retry' | translate }}
</button>
}
}
@case ('done') {
<button mat-flat-button color="primary" (click)="done()">
{{ 'action.done' | translate }}
</button>
}
}
</mat-dialog-actions>
}

210
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;
}

368
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<string, string>();
availableConnectivityTypes: string[] = [];
selectedConnectivity: string | null = null;
currentView: DeviceInstallView = 'connectivity';
steps: DeviceInstallStep[] = [];
currentStepIndex = 0;
currentMarkdown = '';
currentFormFields: FormFieldDefinition[] = [];
currentFormGroup: UntypedFormGroup | null = null;
passwordVisible: Record<string, boolean> = {};
formValues: Record<string, any> = {};
entityOutputs = new Map<string, EntityStepOutput>();
positionalOutputs = new Map<number, EntityStepOutput>();
entitySteps: EntityStepProgress[] = [];
progressError: string | null = null;
constructor(
@Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData,
private dialogRef: MatDialogRef<TbDeviceInstallDialogComponent>,
private deviceProfileService: DeviceProfileService,
private deviceService: DeviceService,
private dashboardService: DashboardService,
private ruleChainService: RuleChainService
) {}
async ngOnInit(): Promise<void> {
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<string, UntypedFormControl> = {};
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<void> {
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<EntityStepOutput> {
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}`);
}
}
}

4
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,

Loading…
Cancel
Save