Browse Source
* Add device-package.models.ts with install wizard types Defines ConnectivityType, InstallStepType, ENTITY_STEP_TYPES, stepTypeAliasMap, DevicePackageInfo, DeviceInstallStep, form field interfaces, and EntityStepProgress types for the device install wizard. * 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) * fix(iot-hub): fix device install dialog template and translations - Use correct tb-markdown inputs ([data] and [usePlainMarkdown]) - Fix translation key format (hyphens, not dots) - Remove unused ConnectivityType import and resolveImageUrl method - Auto-advance to first step when single connectivity type - Use inline data URIs for helpImage directly * feat(iot-hub): wire device install dialog into all install entry points Branch DEVICE item type to fetch ZIP and open TbDeviceInstallDialogComponent instead of the simple install dialog. Applied to: - Item detail dialog (install button) - Browse component (card install button) - Home component (card install button) * fix(iot-hub): fix build errors in device install dialog - Fix tb-markdown input: [markdownText] → [data] in done view - Fix JSZip entry type annotation for strict TS compilation * fix(iot-hub): reuse existing device profiles and add missing retry translation - Find device profile by name before creating; reuse if exists - Add action.retry translation key (was showing raw key) * feat(iot-hub): add find-or-create for rule chains in device install Reuse existing rule chains by name (same pattern as device profiles). Shared entities (device profile, rule chain) are looked up before creation. Per-install entities (device, dashboard) are always created fresh. * feat(iot-hub): refactor device install dialog to use mat-stepper - Replace single-panel view switching with mat-horizontal-stepper - Group consecutive entity steps into a single "Provisioning" stepper step - Each SHOW_INSTRUCTION and SHOW_FORM is its own stepper step - Add 2s minimum display time per entity creation step - Connectivity selector shown before stepper (not a stepper step) - Progress step auto-advances to next step after completion - Stepper shows completed checkmarks for finished steps * fix(iot-hub): fix stepper navigation by tracking step completion Linear mat-stepper requires steps to be marked completed before advancing. Added completed flag to WizardStep, set it when user clicks Next or when entity steps finish. * feat(iot-hub): add device package install tracking with entity cleanup Backend: - Expand DeviceInstalledItemDescriptor with createdEntityIds and dashboardId - Add POST /api/iot-hub/device/register endpoint to save installed item - Implement reverse-order entity deletion for DEVICE uninstall - Update DEVICE case in updateItemVersion to clean old entities Frontend: - Expand DeviceInstalledItemDescriptor TS model - Add registerDeviceInstall API method - Wire wizard to collect entity IDs and register after completion - Enable dashboard navigation for installed device packages * fix(iot-hub): add type discriminator to device register request body Jackson requires the 'type' field for @JsonTypeInfo polymorphic deserialization of DeviceInstalledItemDescriptor. * feat(iot-hub): show entity type in provisioning progress rows Each progress row now displays "Device Profile — ESP32 Dev Kit" instead of just "ESP32 Dev Kit", making it clear what type of entity is being provisioned. * fix(iot-hub): suppress global error toast and add Back button on provisioning error - Add ignoreErrors: true to all entity creation API calls so errors are only shown in the dialog, not as global toast notifications - Add Back button on provisioning error to navigate back to the last form step, allowing users to fix parameters (e.g. device name) - Make form steps editable so stepper allows backward navigation - Reset progress step state when going back so it re-runs with updated form values * fix(iot-hub): resolve ExpressionChangedAfterItHasBeenCheckedError in device install dialog Inject ChangeDetectorRef and call detectChanges() after async state mutations (entity step status changes, stepper auto-advance, ngOnInit completion). The NG0100 error occurred because async operations (setTimeout, HTTP calls) mutated component state outside Angular's change detection cycle. * fix(iot-hub): fix device register endpoint JSON deserialization Accept JsonNode and use JacksonUtil.treeToValue() instead of direct @RequestBody DeviceInstalledItemDescriptor. The @JsonTypeInfo on the parent interface caused Spring's message converter to fail when deserializing the concrete class directly. * fix(iot-hub): fix dashboardId serialization in device register request DashboardId implements EntityId which uses EntityIdDeserializer requiring both entityType and id fields. Frontend was sending {"id":"..."} without entityType, causing "Missing entityType or id!" 400 error. Fix: include entityType: "DASHBOARD" in the dashboardId payload. Add deserialization unit test to verify the exact frontend payload format.pull/15508/head
committed by
GitHub
17 changed files with 1424 additions and 6 deletions
@ -0,0 +1,75 @@ |
|||||
|
/** |
||||
|
* 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. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.iot_hub; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.thingsboard.server.common.data.EntityType; |
||||
|
import org.thingsboard.server.common.data.id.DashboardId; |
||||
|
|
||||
|
import java.util.UUID; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
class DeviceInstalledItemDescriptorTest { |
||||
|
|
||||
|
private static final ObjectMapper mapper = new ObjectMapper(); |
||||
|
|
||||
|
@Test |
||||
|
void deserializeFromFrontendPayload() throws Exception { |
||||
|
// Exact payload the frontend sends after the fix
|
||||
|
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"} |
||||
|
} |
||||
|
"""; |
||||
|
|
||||
|
// Deserialize the same way the controller does (JsonNode → treeToValue)
|
||||
|
JsonNode node = mapper.readTree(json); |
||||
|
DeviceInstalledItemDescriptor descriptor = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); |
||||
|
|
||||
|
assertThat(descriptor).isNotNull(); |
||||
|
assertThat(descriptor.getCreatedEntityIds()).hasSize(3); |
||||
|
assertThat(descriptor.getCreatedEntityIds().get(0).getEntityType()).isEqualTo(EntityType.DEVICE_PROFILE); |
||||
|
assertThat(descriptor.getCreatedEntityIds().get(1).getEntityType()).isEqualTo(EntityType.DEVICE); |
||||
|
assertThat(descriptor.getCreatedEntityIds().get(2).getEntityType()).isEqualTo(EntityType.DASHBOARD); |
||||
|
assertThat(descriptor.getDashboardId()).isNotNull(); |
||||
|
assertThat(descriptor.getDashboardId().getId()).isEqualTo(UUID.fromString("8bc229f0-2e5d-11f1-a802-c35a9af2ebde")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void roundTripSerialization() throws Exception { |
||||
|
DeviceInstalledItemDescriptor original = new DeviceInstalledItemDescriptor(); |
||||
|
DashboardId dashboardId = new DashboardId(UUID.randomUUID()); |
||||
|
original.setDashboardId(dashboardId); |
||||
|
original.setCreatedEntityIds(java.util.List.of(dashboardId)); |
||||
|
|
||||
|
String json = mapper.writeValueAsString(original); |
||||
|
JsonNode node = mapper.readTree(json); |
||||
|
DeviceInstalledItemDescriptor deserialized = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class); |
||||
|
|
||||
|
assertThat(deserialized.getDashboardId().getId()).isEqualTo(original.getDashboardId().getId()); |
||||
|
assertThat(deserialized.getCreatedEntityIds()).hasSize(1); |
||||
|
assertThat(deserialized.getCreatedEntityIds().get(0).getId()).isEqualTo(dashboardId.getId()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,233 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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">{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}</h2> |
||||
|
@if (selectedConnectivity && wizardStarted) { |
||||
|
<span class="tb-device-install-badge">{{ connectivityLabels.get(selectedConnectivity) }}</span> |
||||
|
} |
||||
|
<button mat-icon-button (click)="cancel()" tabindex="-1"> |
||||
|
<mat-icon>close</mat-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
<mat-divider></mat-divider> |
||||
|
|
||||
|
@if (!wizardStarted) { |
||||
|
<!-- Connectivity selector --> |
||||
|
<div class="tb-device-install-body"> |
||||
|
<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)"> |
||||
|
{{ connectivityLabels.get(ct) || ct }} |
||||
|
</button> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<mat-dialog-actions align="end"> |
||||
|
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button> |
||||
|
<button mat-flat-button color="primary" |
||||
|
[disabled]="!selectedConnectivity" |
||||
|
(click)="confirmConnectivity()"> |
||||
|
{{ 'action.next' | translate }} |
||||
|
</button> |
||||
|
</mat-dialog-actions> |
||||
|
} @else { |
||||
|
<!-- Stepper wizard --> |
||||
|
<div class="tb-device-install-stepper-container"> |
||||
|
<mat-horizontal-stepper #installStepper |
||||
|
[linear]="true" |
||||
|
labelPosition="bottom"> |
||||
|
<ng-template matStepperIcon="edit"> |
||||
|
<mat-icon>done</mat-icon> |
||||
|
</ng-template> |
||||
|
|
||||
|
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) { |
||||
|
<mat-step [editable]="ws.type === 'form'" |
||||
|
[completed]="ws.completed" |
||||
|
[stepControl]="ws.type === 'form' ? ws.formGroup : null"> |
||||
|
<ng-template matStepLabel>{{ ws.label }}</ng-template> |
||||
|
|
||||
|
<div class="tb-wizard-step-content"> |
||||
|
@switch (ws.type) { |
||||
|
|
||||
|
@case ('instruction') { |
||||
|
<div class="tb-device-install-instruction"> |
||||
|
<tb-markdown [data]="ws.markdown" |
||||
|
[usePlainMarkdown]="true"> |
||||
|
</tb-markdown> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@case ('form') { |
||||
|
<div class="tb-device-install-form"> |
||||
|
@if (ws.formGroup) { |
||||
|
<form [formGroup]="ws.formGroup"> |
||||
|
@for (field of ws.formFields; 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 (ws.formGroup.controls[field.key]?.hasError('required')) { |
||||
|
<mat-error>{{ field.label + ' is required' }}</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 (ws.formGroup.controls[field.key]?.hasError('required')) { |
||||
|
<mat-error>{{ field.label + ' is required' }}</mat-error> |
||||
|
} |
||||
|
@if (ws.formGroup.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 (ws.formGroup.controls[field.key]?.hasError('required')) { |
||||
|
<mat-error>{{ field.label + ' is required' }}</mat-error> |
||||
|
} |
||||
|
@if (ws.formGroup.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 (ws.formGroup.controls[field.key]?.hasError('required')) { |
||||
|
<mat-error>{{ field.label + ' is required' }}</mat-error> |
||||
|
} |
||||
|
@if (ws.formGroup.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]="field.helpImage" [alt]="field.label"> |
||||
|
} |
||||
|
} |
||||
|
</form> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@case ('progress') { |
||||
|
<div class="tb-device-install-progress"> |
||||
|
@for (ep of ws.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 class="tb-progress-info"> |
||||
|
<span class="tb-progress-label">{{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }}</span> |
||||
|
@if (ep.status === 'error' && ep.errorMessage) { |
||||
|
<span class="tb-progress-error-msg">{{ ep.errorMessage }}</span> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
} |
||||
|
</div> |
||||
|
</mat-step> |
||||
|
} |
||||
|
</mat-horizontal-stepper> |
||||
|
</div> |
||||
|
|
||||
|
<mat-dialog-actions align="end"> |
||||
|
@if (currentWizardStep; as step) { |
||||
|
@switch (step.type) { |
||||
|
@case ('instruction') { |
||||
|
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button> |
||||
|
<button mat-flat-button color="primary" (click)="nextStep()"> |
||||
|
{{ (isLastWizardStep ? 'action.done' : 'action.next') | translate }} |
||||
|
</button> |
||||
|
} |
||||
|
@case ('form') { |
||||
|
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button> |
||||
|
<button mat-flat-button color="primary" (click)="nextStep()"> |
||||
|
{{ 'action.next' | translate }} |
||||
|
</button> |
||||
|
} |
||||
|
@case ('progress') { |
||||
|
@if (step.progressError) { |
||||
|
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button> |
||||
|
<button mat-button (click)="goBackToForm()">{{ 'action.back' | translate }}</button> |
||||
|
<button mat-flat-button color="primary" (click)="retryEntitySteps(step)"> |
||||
|
{{ 'action.retry' | translate }} |
||||
|
</button> |
||||
|
} @else if (step.progressDone && isLastWizardStep) { |
||||
|
<button mat-flat-button color="primary" (click)="done()"> |
||||
|
{{ 'action.done' | translate }} |
||||
|
</button> |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</mat-dialog-actions> |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,233 @@ |
|||||
|
/** |
||||
|
* 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: 720px; |
||||
|
max-width: 90vw; |
||||
|
max-height: 85vh; |
||||
|
} |
||||
|
|
||||
|
.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-loading { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
padding: 48px 0; |
||||
|
} |
||||
|
|
||||
|
// Connectivity selector (before stepper) |
||||
|
.tb-device-install-body { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
padding: 0 24px 24px; |
||||
|
min-height: 120px; |
||||
|
} |
||||
|
|
||||
|
.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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Stepper container |
||||
|
.tb-device-install-stepper-container { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
min-height: 200px; |
||||
|
|
||||
|
::ng-deep { |
||||
|
.mat-horizontal-stepper-header-container { |
||||
|
padding: 0 16px; |
||||
|
} |
||||
|
|
||||
|
.mat-horizontal-content-container { |
||||
|
padding: 0 24px 16px; |
||||
|
} |
||||
|
|
||||
|
.mat-step-header { |
||||
|
padding: 0 12px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Step content wrapper |
||||
|
.tb-wizard-step-content { |
||||
|
min-height: 120px; |
||||
|
} |
||||
|
|
||||
|
// Instruction view |
||||
|
.tb-device-install-instruction { |
||||
|
::ng-deep tb-markdown { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
::ng-deep { |
||||
|
h1, h2, h3, h4 { |
||||
|
&:first-child { |
||||
|
margin-top: 0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Form view |
||||
|
.tb-device-install-form { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 8px; |
||||
|
|
||||
|
mat-form-field { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.tb-form-field-help { |
||||
|
color: rgba(0, 0, 0, 0.54); |
||||
|
font-size: 12px; |
||||
|
line-height: 16px; |
||||
|
margin-top: -4px; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.tb-form-field-help-image { |
||||
|
max-width: 100%; |
||||
|
margin-top: 4px; |
||||
|
margin-bottom: 8px; |
||||
|
border-radius: 4px; |
||||
|
border: 1px solid rgba(0, 0, 0, 0.12); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Progress view |
||||
|
.tb-device-install-progress { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.tb-progress-row { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
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-info { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 4px; |
||||
|
min-height: 24px; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.tb-progress-label { |
||||
|
font-size: 14px; |
||||
|
line-height: 20px; |
||||
|
color: rgba(0, 0, 0, 0.87); |
||||
|
} |
||||
|
|
||||
|
.tb-progress-error-msg { |
||||
|
font-size: 12px; |
||||
|
color: #c62828; |
||||
|
font-family: monospace; |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-word; |
||||
|
} |
||||
|
|
||||
|
mat-dialog-actions { |
||||
|
padding: 8px 24px 16px; |
||||
|
} |
||||
@ -0,0 +1,527 @@ |
|||||
|
///
|
||||
|
/// 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 { ChangeDetectorRef, Component, Inject, OnInit, ViewChild } from '@angular/core'; |
||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; |
||||
|
import { MatStepper } from '@angular/material/stepper'; |
||||
|
import { firstValueFrom } from 'rxjs'; |
||||
|
import { PageLink } from '@shared/models/page/page-link'; |
||||
|
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 { |
||||
|
connectivityTypeTranslations, |
||||
|
DeviceInstallStep, |
||||
|
DevicePackageInfo, |
||||
|
ENTITY_STEP_TYPES, |
||||
|
EntityStepOutput, |
||||
|
EntityStepProgress, |
||||
|
FormFieldDefinition, |
||||
|
FormFieldType, |
||||
|
InstallStepType, |
||||
|
stepTypeAliasMap |
||||
|
} from '@shared/models/iot-hub/device-package.models'; |
||||
|
|
||||
|
export interface DeviceInstallDialogData { |
||||
|
item: MpItemVersionView; |
||||
|
zipData: ArrayBuffer; |
||||
|
iotHubApiService: IotHubApiService; |
||||
|
} |
||||
|
|
||||
|
export type WizardStepType = 'instruction' | 'form' | 'progress'; |
||||
|
|
||||
|
export interface WizardStep { |
||||
|
type: WizardStepType; |
||||
|
label: string; |
||||
|
rawSteps: DeviceInstallStep[]; |
||||
|
completed: boolean; |
||||
|
// Instruction
|
||||
|
markdown?: string; |
||||
|
// Form
|
||||
|
formFields?: FormFieldDefinition[]; |
||||
|
formGroup?: UntypedFormGroup; |
||||
|
// Progress
|
||||
|
entitySteps?: EntityStepProgress[]; |
||||
|
progressError?: string; |
||||
|
progressDone?: boolean; |
||||
|
} |
||||
|
|
||||
|
const ENTITY_STEP_MIN_DELAY = 2000; |
||||
|
|
||||
|
@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 { |
||||
|
|
||||
|
@ViewChild('installStepper', {static: false}) stepper: MatStepper; |
||||
|
|
||||
|
loading = true; |
||||
|
packageInfo: DevicePackageInfo; |
||||
|
zipFiles = new Map<string, string>(); |
||||
|
|
||||
|
// Connectivity
|
||||
|
showConnectivitySelector = false; |
||||
|
availableConnectivityTypes: string[] = []; |
||||
|
selectedConnectivity: string | null = null; |
||||
|
connectivityLabels = connectivityTypeTranslations; |
||||
|
|
||||
|
// Wizard
|
||||
|
wizardSteps: WizardStep[] = []; |
||||
|
wizardStarted = false; |
||||
|
passwordVisible: Record<string, boolean> = {}; |
||||
|
|
||||
|
// Variable resolution state
|
||||
|
formValues: Record<string, any> = {}; |
||||
|
entityOutputs = new Map<string, EntityStepOutput>(); |
||||
|
positionalOutputs = new Map<number, EntityStepOutput>(); |
||||
|
|
||||
|
constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData, |
||||
|
private dialogRef: MatDialogRef<TbDeviceInstallDialogComponent>, |
||||
|
private cdr: ChangeDetectorRef, |
||||
|
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) as [string, any][]) { |
||||
|
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]; |
||||
|
this.showConnectivitySelector = false; |
||||
|
this.startWizard(); |
||||
|
} else { |
||||
|
this.showConnectivitySelector = true; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
console.error('Failed to parse device package ZIP', e); |
||||
|
} |
||||
|
this.loading = false; |
||||
|
this.cdr.detectChanges(); |
||||
|
} |
||||
|
|
||||
|
// --- Connectivity ---
|
||||
|
|
||||
|
selectConnectivity(ct: string): void { |
||||
|
this.selectedConnectivity = ct; |
||||
|
} |
||||
|
|
||||
|
confirmConnectivity(): void { |
||||
|
if (!this.selectedConnectivity) { |
||||
|
return; |
||||
|
} |
||||
|
this.startWizard(); |
||||
|
} |
||||
|
|
||||
|
// --- Wizard ---
|
||||
|
|
||||
|
get isLastWizardStep(): boolean { |
||||
|
return this.stepper && this.stepper.selectedIndex === this.wizardSteps.length - 1; |
||||
|
} |
||||
|
|
||||
|
get currentWizardStep(): WizardStep | null { |
||||
|
if (!this.stepper || !this.wizardSteps.length) { |
||||
|
return null; |
||||
|
} |
||||
|
return this.wizardSteps[this.stepper.selectedIndex] ?? null; |
||||
|
} |
||||
|
|
||||
|
nextStep(): void { |
||||
|
const step = this.currentWizardStep; |
||||
|
if (!step) { |
||||
|
return; |
||||
|
} |
||||
|
if (step.type === 'form') { |
||||
|
if (step.formGroup?.invalid) { |
||||
|
step.formGroup.markAllAsTouched(); |
||||
|
return; |
||||
|
} |
||||
|
Object.assign(this.formValues, step.formGroup.getRawValue()); |
||||
|
} |
||||
|
if (this.isLastWizardStep) { |
||||
|
this.done(); |
||||
|
return; |
||||
|
} |
||||
|
step.completed = true; |
||||
|
this.stepper.next(); |
||||
|
this.onStepActivated(); |
||||
|
} |
||||
|
|
||||
|
done(): void { |
||||
|
this.dialogRef.close('installed'); |
||||
|
} |
||||
|
|
||||
|
cancel(): void { |
||||
|
this.dialogRef.close(false); |
||||
|
} |
||||
|
|
||||
|
retryEntitySteps(step: WizardStep): void { |
||||
|
step.progressError = null; |
||||
|
this.runEntitySteps(step); |
||||
|
} |
||||
|
|
||||
|
goBackToForm(): void { |
||||
|
// Find the last form step before the current progress step
|
||||
|
const currentIdx = this.stepper.selectedIndex; |
||||
|
for (let i = currentIdx - 1; i >= 0; i--) { |
||||
|
if (this.wizardSteps[i].type === 'form') { |
||||
|
// Reset the progress step so it re-runs when we come back
|
||||
|
const progressStep = this.wizardSteps[currentIdx]; |
||||
|
progressStep.entitySteps = null; |
||||
|
progressStep.progressError = null; |
||||
|
progressStep.progressDone = false; |
||||
|
progressStep.completed = false; |
||||
|
// Allow navigation back by making intermediate steps editable
|
||||
|
for (let j = i; j < currentIdx; j++) { |
||||
|
this.wizardSteps[j].completed = false; |
||||
|
} |
||||
|
this.stepper.selectedIndex = i; |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getPatternErrorMessage(field: FormFieldDefinition): string { |
||||
|
return field.validators?.[0]?.message || 'Invalid format'; |
||||
|
} |
||||
|
|
||||
|
// --- Variable Resolution ---
|
||||
|
|
||||
|
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 as any)[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 as any)[prop]); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return '${' + key + '}'; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// --- Private ---
|
||||
|
|
||||
|
private startWizard(): void { |
||||
|
this.formValues = {}; |
||||
|
this.entityOutputs.clear(); |
||||
|
this.positionalOutputs.clear(); |
||||
|
this.passwordVisible = {}; |
||||
|
this.buildWizardSteps(); |
||||
|
this.wizardStarted = true; |
||||
|
|
||||
|
// Activate the first step after the stepper renders
|
||||
|
setTimeout(() => this.onStepActivated(), 0); |
||||
|
} |
||||
|
|
||||
|
private buildWizardSteps(): void { |
||||
|
const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || []; |
||||
|
this.wizardSteps = []; |
||||
|
let i = 0; |
||||
|
while (i < rawSteps.length) { |
||||
|
const step = rawSteps[i]; |
||||
|
if (step.type === InstallStepType.SHOW_INSTRUCTION) { |
||||
|
this.wizardSteps.push({ |
||||
|
type: 'instruction', |
||||
|
label: step.name, |
||||
|
rawSteps: [step], |
||||
|
completed: false |
||||
|
}); |
||||
|
i++; |
||||
|
} else if (step.type === InstallStepType.SHOW_FORM) { |
||||
|
this.wizardSteps.push({ |
||||
|
type: 'form', |
||||
|
label: step.name, |
||||
|
rawSteps: [step], |
||||
|
completed: false |
||||
|
}); |
||||
|
i++; |
||||
|
} else if (ENTITY_STEP_TYPES.has(step.type)) { |
||||
|
// Group consecutive entity steps
|
||||
|
const group: DeviceInstallStep[] = []; |
||||
|
while (i < rawSteps.length && ENTITY_STEP_TYPES.has(rawSteps[i].type)) { |
||||
|
group.push(rawSteps[i]); |
||||
|
i++; |
||||
|
} |
||||
|
this.wizardSteps.push({ |
||||
|
type: 'progress', |
||||
|
label: 'Provisioning', |
||||
|
rawSteps: group, |
||||
|
completed: false |
||||
|
}); |
||||
|
} else { |
||||
|
// Skip unsupported steps (CONVERTER, INTEGRATION)
|
||||
|
i++; |
||||
|
} |
||||
|
} |
||||
|
// Initialize form groups
|
||||
|
for (const ws of this.wizardSteps) { |
||||
|
if (ws.type === 'form') { |
||||
|
this.initFormStep(ws); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private initFormStep(ws: WizardStep): void { |
||||
|
const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]'; |
||||
|
ws.formFields = JSON.parse(formJson) as FormFieldDefinition[]; |
||||
|
const controls: Record<string, UntypedFormControl> = {}; |
||||
|
for (const field of ws.formFields) { |
||||
|
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.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); |
||||
|
controls[field.key] = new UntypedFormControl(initialValue, validators); |
||||
|
if (field.type === FormFieldType.PASSWORD) { |
||||
|
this.passwordVisible[field.key] = false; |
||||
|
} |
||||
|
} |
||||
|
ws.formGroup = new UntypedFormGroup(controls); |
||||
|
} |
||||
|
|
||||
|
private onStepActivated(): void { |
||||
|
const step = this.currentWizardStep; |
||||
|
if (!step) { |
||||
|
return; |
||||
|
} |
||||
|
if (step.type === 'instruction') { |
||||
|
const raw = this.zipFiles.get(step.rawSteps[0].file) || ''; |
||||
|
step.markdown = this.resolveVariables(raw); |
||||
|
} else if (step.type === 'progress' && !step.progressDone) { |
||||
|
this.initAndRunEntitySteps(step); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private initAndRunEntitySteps(ws: WizardStep): void { |
||||
|
ws.entitySteps = ws.rawSteps.map(s => ({ |
||||
|
step: s, |
||||
|
status: 'pending' as const, |
||||
|
resolvedName: this.resolveVariables(s.name) |
||||
|
})); |
||||
|
ws.progressError = null; |
||||
|
ws.progressDone = false; |
||||
|
this.runEntitySteps(ws); |
||||
|
} |
||||
|
|
||||
|
private async runEntitySteps(ws: WizardStep): Promise<void> { |
||||
|
for (const ep of ws.entitySteps) { |
||||
|
if (ep.status === 'success') { |
||||
|
continue; |
||||
|
} |
||||
|
ep.status = 'running'; |
||||
|
ep.errorMessage = null; |
||||
|
this.cdr.detectChanges(); |
||||
|
try { |
||||
|
const startTime = Date.now(); |
||||
|
const output = await this.createEntity(ep.step); |
||||
|
// Ensure minimum visible time per step
|
||||
|
const elapsed = Date.now() - startTime; |
||||
|
if (elapsed < ENTITY_STEP_MIN_DELAY) { |
||||
|
await this.delay(ENTITY_STEP_MIN_DELAY - elapsed); |
||||
|
} |
||||
|
ep.entityOutput = output; |
||||
|
ep.status = 'success'; |
||||
|
|
||||
|
const alias = stepTypeAliasMap[ep.step.type]; |
||||
|
if (alias) { |
||||
|
this.entityOutputs.set(alias, output); |
||||
|
} |
||||
|
const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || []; |
||||
|
const stepIdx = rawSteps.indexOf(ep.step); |
||||
|
if (stepIdx >= 0) { |
||||
|
this.positionalOutputs.set(stepIdx + 1, output); |
||||
|
} |
||||
|
|
||||
|
// Re-resolve names of remaining pending steps
|
||||
|
for (const remaining of ws.entitySteps) { |
||||
|
if (remaining.status === 'pending') { |
||||
|
remaining.resolvedName = this.resolveVariables(remaining.step.name); |
||||
|
} |
||||
|
} |
||||
|
this.cdr.detectChanges(); |
||||
|
} catch (err: any) { |
||||
|
ep.status = 'error'; |
||||
|
ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; |
||||
|
ws.progressError = ep.errorMessage; |
||||
|
this.cdr.detectChanges(); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// All done — register install with created entity IDs
|
||||
|
ws.progressDone = true; |
||||
|
ws.completed = true; |
||||
|
try { |
||||
|
const createdEntityIds = this.collectCreatedEntityIds(); |
||||
|
const dashboardId = this.findCreatedDashboardId(); |
||||
|
await firstValueFrom( |
||||
|
this.data.iotHubApiService.registerDeviceInstall( |
||||
|
this.data.item.id as string, |
||||
|
{ createdEntityIds, dashboardId }, |
||||
|
{ ignoreLoading: true } |
||||
|
) |
||||
|
); |
||||
|
} catch (_e) { |
||||
|
// Non-critical — entities are created, tracking registration failed
|
||||
|
console.error('Failed to register device install', _e); |
||||
|
} |
||||
|
|
||||
|
// Auto-advance to next step after a short pause
|
||||
|
if (!this.isLastWizardStep) { |
||||
|
await this.delay(500); |
||||
|
this.stepper.next(); |
||||
|
this.cdr.detectChanges(); |
||||
|
this.onStepActivated(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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 existing = await this.findDeviceProfileByName(template.name); |
||||
|
if (existing) { |
||||
|
return existing; |
||||
|
} |
||||
|
const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true})); |
||||
|
return { id: result.id.id, name: result.name }; |
||||
|
} |
||||
|
case InstallStepType.DEVICE: { |
||||
|
const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); |
||||
|
const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id, false, {ignoreErrors: true})); |
||||
|
return { id: result.id.id, name: result.name, token: creds.credentialsId }; |
||||
|
} |
||||
|
case InstallStepType.DASHBOARD: { |
||||
|
const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); |
||||
|
return { id: result.id.id, name: result.title }; |
||||
|
} |
||||
|
case InstallStepType.RULE_CHAIN: { |
||||
|
const ruleChain = template.ruleChain || template; |
||||
|
const metadata = template.metadata; |
||||
|
const existing = await this.findRuleChainByName(ruleChain.name); |
||||
|
if (existing) { |
||||
|
return existing; |
||||
|
} |
||||
|
const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true})); |
||||
|
if (metadata) { |
||||
|
metadata.ruleChainId = saved.id; |
||||
|
await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true})); |
||||
|
} |
||||
|
return { id: saved.id.id, name: saved.name }; |
||||
|
} |
||||
|
default: |
||||
|
throw new Error(`Unsupported entity step type: ${step.type}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async findDeviceProfileByName(name: string): Promise<EntityStepOutput | null> { |
||||
|
const profiles = await firstValueFrom(this.deviceProfileService.getDeviceProfileNames()); |
||||
|
const match = profiles.find(p => p.name === name); |
||||
|
return match ? { id: match.id.id, name: match.name } : null; |
||||
|
} |
||||
|
|
||||
|
private async findRuleChainByName(name: string): Promise<EntityStepOutput | null> { |
||||
|
const page = await firstValueFrom(this.ruleChainService.getRuleChains(new PageLink(100, 0, name))); |
||||
|
const match = page.data.find(rc => rc.name === name); |
||||
|
return match ? { id: match.id.id, name: match.name } : null; |
||||
|
} |
||||
|
|
||||
|
private collectCreatedEntityIds(): { entityType: string; id: string }[] { |
||||
|
const ids: { entityType: string; id: string }[] = []; |
||||
|
for (const ws of this.wizardSteps) { |
||||
|
if (ws.type === 'progress' && ws.entitySteps) { |
||||
|
for (const ep of ws.entitySteps) { |
||||
|
if (ep.status === 'success' && ep.entityOutput) { |
||||
|
const entityType = this.stepTypeToEntityType(ep.step.type); |
||||
|
if (entityType) { |
||||
|
ids.push({ entityType, id: ep.entityOutput.id }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return ids; |
||||
|
} |
||||
|
|
||||
|
private findCreatedDashboardId(): { entityType: string; id: string } | undefined { |
||||
|
for (const ws of this.wizardSteps) { |
||||
|
if (ws.type === 'progress' && ws.entitySteps) { |
||||
|
for (const ep of ws.entitySteps) { |
||||
|
if (ep.step.type === InstallStepType.DASHBOARD && ep.status === 'success' && ep.entityOutput) { |
||||
|
return { entityType: 'DASHBOARD', id: ep.entityOutput.id }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
private stepTypeToEntityType(stepType: InstallStepType): string | null { |
||||
|
switch (stepType) { |
||||
|
case InstallStepType.DEVICE_PROFILE: return 'DEVICE_PROFILE'; |
||||
|
case InstallStepType.DEVICE: return 'DEVICE'; |
||||
|
case InstallStepType.DASHBOARD: return 'DASHBOARD'; |
||||
|
case InstallStepType.RULE_CHAIN: return 'RULE_CHAIN'; |
||||
|
default: return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private delay(ms: number): Promise<void> { |
||||
|
return new Promise(resolve => setTimeout(resolve, ms)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,134 @@ |
|||||
|
///
|
||||
|
/// 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.
|
||||
|
///
|
||||
|
|
||||
|
export enum ConnectivityType { |
||||
|
DIRECT_HTTP = 'DIRECT_HTTP', |
||||
|
DIRECT_MQTT = 'DIRECT_MQTT', |
||||
|
DIRECT_COAP = 'DIRECT_COAP', |
||||
|
DIRECT_LWM2M = 'DIRECT_LWM2M', |
||||
|
DIRECT_SNMP = 'DIRECT_SNMP', |
||||
|
GATEWAY_MQTT = 'GATEWAY_MQTT', |
||||
|
GATEWAY_MODBUS = 'GATEWAY_MODBUS', |
||||
|
GATEWAY_OPCUA = 'GATEWAY_OPCUA', |
||||
|
CHIRPSTACK = 'CHIRPSTACK', |
||||
|
INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', |
||||
|
INTEGRATION_TTN = 'INTEGRATION_TTN', |
||||
|
INTEGRATION_LORIOT = 'INTEGRATION_LORIOT' |
||||
|
} |
||||
|
|
||||
|
export const connectivityTypeTranslations = new Map<string, string>( |
||||
|
[ |
||||
|
[ConnectivityType.DIRECT_HTTP, 'HTTP'], |
||||
|
[ConnectivityType.DIRECT_MQTT, 'MQTT'], |
||||
|
[ConnectivityType.DIRECT_COAP, 'CoAP'], |
||||
|
[ConnectivityType.DIRECT_LWM2M, 'LwM2M'], |
||||
|
[ConnectivityType.DIRECT_SNMP, 'SNMP'], |
||||
|
[ConnectivityType.GATEWAY_MQTT, 'MQTT Gateway'], |
||||
|
[ConnectivityType.GATEWAY_MODBUS, 'Modbus Gateway'], |
||||
|
[ConnectivityType.GATEWAY_OPCUA, 'OPC-UA Gateway'], |
||||
|
[ConnectivityType.CHIRPSTACK, 'ChirpStack'], |
||||
|
[ConnectivityType.INTEGRATION_CHIRPSTACK, 'ChirpStack (PE)'], |
||||
|
[ConnectivityType.INTEGRATION_TTN, 'The Things Stack'], |
||||
|
[ConnectivityType.INTEGRATION_LORIOT, 'LORIOT'] |
||||
|
] |
||||
|
); |
||||
|
|
||||
|
export enum InstallStepType { |
||||
|
SHOW_INSTRUCTION = 'SHOW_INSTRUCTION', |
||||
|
SHOW_FORM = 'SHOW_FORM', |
||||
|
DEVICE_PROFILE = 'DEVICE_PROFILE', |
||||
|
CONVERTER = 'CONVERTER', |
||||
|
INTEGRATION = 'INTEGRATION', |
||||
|
DEVICE = 'DEVICE', |
||||
|
DASHBOARD = 'DASHBOARD', |
||||
|
RULE_CHAIN = 'RULE_CHAIN' |
||||
|
} |
||||
|
|
||||
|
export const ENTITY_STEP_TYPES: Set<string> = new Set([ |
||||
|
InstallStepType.DEVICE_PROFILE, |
||||
|
InstallStepType.DEVICE, |
||||
|
InstallStepType.DASHBOARD, |
||||
|
InstallStepType.RULE_CHAIN |
||||
|
]); |
||||
|
|
||||
|
export const stepTypeAliasMap: Record<string, string> = { |
||||
|
[InstallStepType.DEVICE_PROFILE]: 'deviceProfile', |
||||
|
[InstallStepType.DEVICE]: 'device', |
||||
|
[InstallStepType.DASHBOARD]: 'dashboard', |
||||
|
[InstallStepType.RULE_CHAIN]: 'ruleChain' |
||||
|
}; |
||||
|
|
||||
|
export interface DeviceInstallStep { |
||||
|
type: InstallStepType; |
||||
|
name: string; |
||||
|
file?: string; |
||||
|
template?: string; |
||||
|
} |
||||
|
|
||||
|
export interface DevicePackageInfo { |
||||
|
name: string; |
||||
|
description: string; |
||||
|
vendor: string; |
||||
|
hardwareType: string; |
||||
|
connectivityTypes: string[]; |
||||
|
installSteps: Record<string, DeviceInstallStep[]>; |
||||
|
} |
||||
|
|
||||
|
export enum FormFieldType { |
||||
|
STRING = 'STRING', |
||||
|
INTEGER = 'INTEGER', |
||||
|
BOOLEAN = 'BOOLEAN', |
||||
|
SELECT = 'SELECT', |
||||
|
PASSWORD = 'PASSWORD' |
||||
|
} |
||||
|
|
||||
|
export interface FormFieldValidator { |
||||
|
pattern: string; |
||||
|
message: string; |
||||
|
} |
||||
|
|
||||
|
export interface FormFieldOption { |
||||
|
value: string; |
||||
|
label: string; |
||||
|
} |
||||
|
|
||||
|
export interface FormFieldDefinition { |
||||
|
key: string; |
||||
|
label: string; |
||||
|
type: FormFieldType; |
||||
|
defaultValue?: any; |
||||
|
required?: boolean; |
||||
|
helpText?: string; |
||||
|
helpImage?: string; |
||||
|
validators?: FormFieldValidator[]; |
||||
|
options?: FormFieldOption[]; |
||||
|
} |
||||
|
|
||||
|
export interface EntityStepOutput { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
token?: string; |
||||
|
} |
||||
|
|
||||
|
export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error'; |
||||
|
|
||||
|
export interface EntityStepProgress { |
||||
|
step: DeviceInstallStep; |
||||
|
status: EntityStepStatus; |
||||
|
resolvedName?: string; |
||||
|
entityOutput?: EntityStepOutput; |
||||
|
errorMessage?: string; |
||||
|
} |
||||
Loading…
Reference in new issue