Browse Source
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
4 changed files with 798 additions and 1 deletions
@ -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> |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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}`); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue