Browse Source

feat(iot-hub): set rule chain as default rule chain on profile during install

Extend the IoT Hub install dialog so installing a rule chain can also set
it as the Default rule chain on a Device or Asset profile in one step.

The confirm step shows three actions: Cancel, Install (creates the chain
without targeting any profile), and Set for profile (opens a picker step).
The picker step has Cancel, Back, and Install — confirming with a profile
that already has a non-null defaultRuleChainId routes through a
confirm-overwrite step before replacing it. CALCULATED_FIELD keeps its
existing single-button flow unchanged.

Backend:
* RuleChainInstalledItemDescriptor gains a nullable EntityId
  targetProfileId field, persisted in the existing descriptor JSON column
  (no schema migration; @JsonIgnoreProperties(ignoreUnknown=true) handles
  pre-existing rows).
* DefaultIotHubService.installRuleChain() now accepts SecurityUser and
  JsonNode data; a new setAsDefaultRuleChain() helper applies the chain
  as Default rule chain on the selected DEVICE_PROFILE or ASSET_PROFILE
  via the existing tbDeviceProfileService / tbAssetProfileService save
  paths (so the change shows up in the tenant audit log as a normal
  profile update).

Frontend:
* iot-hub-install-dialog.component grows a per-ItemType selectEntityConfig
  map, a 'confirm-overwrite' state, and the methods
  installAsEntityProfileDefault, selectEntityBack, resolveOverwrite,
  confirmOverwriteReplace, confirmOverwriteCancel.
* tb-entity-select gets a 512px min-width above the gt-sm breakpoint so
  the picker renders at a consistent width regardless of the prompt text
  length (mirrors the pattern in recipient-notification-dialog).
* New i18n keys: rule-chain-install-desc, rule-chain-install-as-default,
  select-profile-for-rule-chain, rule-chain-overwrite-title,
  rule-chain-overwrite-body, rule-chain-overwrite-replace.
pull/15645/head
dshvaika 2 weeks ago
parent
commit
ca62471d5c
  1. 52
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  2. 2
      common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java
  3. 55
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
  4. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss
  5. 124
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
  6. 6
      ui-ngx/src/assets/locale/locale.constant-en_US.json

52
application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java

@ -21,10 +21,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
@ -47,6 +49,7 @@ import org.thingsboard.server.common.data.rule.NodeConnectionInfo;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.device.DeviceProfileService;
@ -55,6 +58,7 @@ import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.asset.profile.TbAssetProfileService;
import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService;
import org.thingsboard.server.service.entitiy.device.TbDeviceService;
@ -84,6 +88,8 @@ public class DefaultIotHubService implements IotHubService {
private final RuleChainService ruleChainService;
private final TbRuleChainService tbRuleChainService;
private final TbDeviceProfileService tbDeviceProfileService;
private final AssetProfileService assetProfileService;
private final TbAssetProfileService tbAssetProfileService;
private final IotHubInstalledItemService iotHubInstalledItemService;
private final WidgetTypeService widgetTypeService;
private final DashboardService dashboardService;
@ -115,7 +121,7 @@ public class DefaultIotHubService implements IotHubService {
case "CALCULATED_FIELD" -> installCalculatedField(user, tenantId, fileData, data);
case "ALARM_RULE" -> throw new IllegalArgumentException(
"Alarm Rules require ThingsBoard 4.3 or later. Please update your platform instance to install Alarm Rule packages.");
case "RULE_CHAIN" -> installRuleChain(tenantId, fileData);
case "RULE_CHAIN" -> installRuleChain(user, tenantId, fileData, data);
case "DEVICE" -> installDeviceProfile(user, tenantId, fileData);
case "SOLUTION_TEMPLATE" -> installSolution(user, tenantId, fileData, request);
default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType);
@ -194,7 +200,8 @@ public class DefaultIotHubService implements IotHubService {
return descriptor;
}
private RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) throws Exception {
private RuleChainInstalledItemDescriptor installRuleChain(
SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception {
JsonNode json = JacksonUtil.toJsonNode(new String(fileData));
RuleChain ruleChain;
@ -220,11 +227,52 @@ public class DefaultIotHubService implements IotHubService {
ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration);
log.debug("[{}] Rule chain installed: {}", tenantId, savedRuleChain.getName());
RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor();
descriptor.setRuleChainId(savedRuleChain.getId());
if (data != null && data.has("entityId") && !data.get("entityId").isNull()) {
EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class);
setAsDefaultRuleChain(user, tenantId, entityId, savedRuleChain.getId());
descriptor.setTargetProfileId(entityId);
}
return descriptor;
}
private void setAsDefaultRuleChain(SecurityUser user, TenantId tenantId,
EntityId entityId, RuleChainId ruleChainId) throws Exception {
switch (entityId.getEntityType()) {
case DEVICE_PROFILE -> {
DeviceProfile profile = deviceProfileService.findDeviceProfileById(
tenantId, (DeviceProfileId) entityId);
if (profile == null) {
throw new IllegalArgumentException(
"Device profile not found: " + entityId.getId());
}
profile.setDefaultRuleChainId(ruleChainId);
tbDeviceProfileService.save(profile, user);
log.debug("[{}] Set rule chain {} as default on device profile {}",
tenantId, ruleChainId.getId(), entityId.getId());
}
case ASSET_PROFILE -> {
AssetProfile profile = assetProfileService.findAssetProfileById(
tenantId, (AssetProfileId) entityId);
if (profile == null) {
throw new IllegalArgumentException(
"Asset profile not found: " + entityId.getId());
}
profile.setDefaultRuleChainId(ruleChainId);
tbAssetProfileService.save(profile, user);
log.debug("[{}] Set rule chain {} as default on asset profile {}",
tenantId, ruleChainId.getId(), entityId.getId());
}
default -> throw new IllegalArgumentException(
"Rule chain can only be set as default on device or asset profile, got: "
+ entityId.getEntityType());
}
}
private DeviceInstalledItemDescriptor installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception {
DeviceProfile deviceProfile;
try {

2
common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java

@ -16,11 +16,13 @@
package org.thingsboard.server.common.data.iot_hub;
import lombok.Data;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
@Data
public class RuleChainInstalledItemDescriptor implements IotHubInstalledItemDescriptor {
private RuleChainId ruleChainId;
private EntityId targetProfileId;
}

55
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html

@ -19,20 +19,35 @@
@switch (state) {
@case ('confirm') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-desc' | translate }}</p>
@if (item.type === ItemType.RULE_CHAIN) {
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.rule-chain-install-desc' | translate }}</p>
} @else {
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-desc' | translate }}</p>
}
}
@case ('select-entity') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.select-entity-for-cf' | translate:{ name: item.name } }}</p>
@if (activeSelectEntityConfig?.promptKey) {
<p class="tb-iot-hub-install-desc"
[innerHTML]="activeSelectEntityConfig.promptKey | translate:{ name: item.name }"></p>
}
<tb-entity-select
[(ngModel)]="selectedEntityId"
[allowedEntityTypes]="cfEntityTypes"
[defaultEntityType]="defaultCfEntityType"
[allowedEntityTypes]="activeSelectEntityConfig.allowed"
[defaultEntityType]="activeSelectEntityConfig.defaultType"
[filterAllowedEntityTypes]="false"
appearance="outline"
required>
[required]="activeSelectEntityConfig.required">
</tb-entity-select>
}
@case ('confirm-overwrite') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.rule-chain-overwrite-title' | translate }}</h2>
<p class="tb-iot-hub-install-desc"
[innerHTML]="'iot-hub.rule-chain-overwrite-body' | translate:{
profile: pendingOverwrite?.profileName,
existing: pendingOverwrite?.existingRuleChainName
}"></p>
}
@case ('installing') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-desc' | translate }}</p>
@ -58,11 +73,37 @@
@switch (state) {
@case ('confirm') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="install()">{{ 'iot-hub.install' | translate }}</button>
@if (item.type === ItemType.RULE_CHAIN) {
<button mat-stroked-button (click)="install()">{{ 'iot-hub.install' | translate }}</button>
<button mat-flat-button color="primary" (click)="installAsEntityProfileDefault()">
{{ 'iot-hub.rule-chain-install-as-default' | translate }}
</button>
} @else {
<button mat-flat-button color="primary" (click)="install()">{{ 'iot-hub.install' | translate }}</button>
}
}
@case ('select-entity') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" [disabled]="!selectedEntityId" (click)="doInstall()">{{ 'iot-hub.install' | translate }}</button>
@if (item.type === ItemType.RULE_CHAIN) {
<button mat-button (click)="selectEntityBack()">{{ 'action.back' | translate }}</button>
<button mat-flat-button color="primary"
[disabled]="!selectedEntityId"
(click)="onSelectEntityInstall()">
{{ 'iot-hub.install' | translate }}
</button>
} @else {
<button mat-flat-button color="primary"
[disabled]="activeSelectEntityConfig?.required && !selectedEntityId"
(click)="onSelectEntityInstall()">
{{ 'iot-hub.install' | translate }}
</button>
}
}
@case ('confirm-overwrite') {
<button mat-button (click)="confirmOverwriteCancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="confirmOverwriteReplace()">
{{ 'iot-hub.rule-chain-overwrite-replace' | translate }}
</button>
}
@case ('installing') {
<button mat-button disabled>{{ 'action.cancel' | translate }}</button>

8
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss

@ -14,6 +14,14 @@
* limitations under the License.
*/
@import "../../../../../scss/constants";
tb-entity-select {
@media #{$mat-gt-sm} {
min-width: 512px;
}
}
.tb-iot-hub-install-title {
font-size: 20px;
font-weight: 600;

124
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts

@ -29,12 +29,37 @@ import { EntityType } from '@shared/models/entity-type.models';
import { EntityId } from '@shared/models/id/entity-id';
import { resolveEntityDetailsUrl } from './iot-hub-components.models';
import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { DeviceProfileService } from '@core/http/device-profile.service';
import { AssetProfileService } from '@core/http/asset-profile.service';
import { RuleChainService } from '@core/http/rule-chain.service';
import { RuleChainId } from '@shared/models/id/rule-chain-id';
interface SelectEntityConfig {
allowed: EntityType[];
defaultType: EntityType;
required: boolean;
promptKey?: string;
}
interface PendingOverwrite {
entityId: EntityId;
profileName: string;
existingRuleChainName: string;
}
export interface IotHubInstallDialogData {
item: MpItemVersionView;
}
export type InstallState = 'select-entity' | 'confirm' | 'installing' | 'success' | 'error';
export type InstallState =
| 'select-entity'
| 'confirm-overwrite'
| 'confirm'
| 'installing'
| 'success'
| 'error';
@Component({
selector: 'tb-iot-hub-install-dialog',
@ -53,8 +78,26 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
entityDetailsUrl: string | null = null;
selectedEntityId: EntityId | null = null;
cfEntityTypes: EntityType[] = [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE];
defaultCfEntityType = EntityType.DEVICE_PROFILE;
pendingOverwrite: PendingOverwrite | null = null;
private readonly selectEntityConfig: Partial<Record<ItemType, SelectEntityConfig>> = {
[ItemType.CALCULATED_FIELD]: {
allowed: [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE],
defaultType: EntityType.DEVICE_PROFILE,
required: true,
promptKey: 'iot-hub.select-entity-for-cf',
},
[ItemType.RULE_CHAIN]: {
allowed: [EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE],
defaultType: EntityType.DEVICE_PROFILE,
required: true,
promptKey: 'iot-hub.select-profile-for-rule-chain',
},
};
get activeSelectEntityConfig(): SelectEntityConfig | null {
return this.selectEntityConfig[this.item.type] ?? null;
}
constructor(
protected store: Store<AppState>,
@ -63,7 +106,10 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
@Inject(MAT_DIALOG_DATA) public data: IotHubInstallDialogData,
private dialog: MatDialog,
private translate: TranslateService,
private iotHubApiService: IotHubApiService
private iotHubApiService: IotHubApiService,
private deviceProfileService: DeviceProfileService,
private assetProfileService: AssetProfileService,
private ruleChainService: RuleChainService
) {
super(store, router, dialogRef);
this.item = data.item;
@ -82,6 +128,76 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
this.doInstall();
}
installAsEntityProfileDefault(): void {
this.state = 'select-entity';
}
selectEntityBack(): void {
this.selectedEntityId = null;
this.state = 'confirm';
}
onSelectEntityInstall(): void {
if (!this.selectedEntityId) {
return;
}
if (this.item.type !== ItemType.RULE_CHAIN) {
this.doInstall();
return;
}
this.resolveOverwrite(this.selectedEntityId).subscribe({
next: (pending) => {
if (pending) {
this.pendingOverwrite = pending;
this.state = 'confirm-overwrite';
} else {
this.pendingOverwrite = null;
this.doInstall();
}
},
error: (err) => {
this.state = 'error';
this.errorMessage = err?.error?.message || err?.message ||
this.translate.instant('iot-hub.install-error', { name: this.item.name });
}
});
}
confirmOverwriteReplace(): void {
this.doInstall();
}
confirmOverwriteCancel(): void {
this.pendingOverwrite = null;
this.state = 'select-entity';
}
private resolveOverwrite(profileEntityId: EntityId): Observable<PendingOverwrite | null> {
const lookupProfile$: Observable<{ name: string; defaultRuleChainId: RuleChainId | null }> =
profileEntityId.entityType === EntityType.DEVICE_PROFILE
? this.deviceProfileService.getDeviceProfile(profileEntityId.id, { ignoreLoading: true }).pipe(
map(p => ({ name: p.name, defaultRuleChainId: p.defaultRuleChainId ?? null }))
)
: this.assetProfileService.getAssetProfile(profileEntityId.id, { ignoreLoading: true }).pipe(
map(p => ({ name: p.name, defaultRuleChainId: p.defaultRuleChainId ?? null }))
);
return lookupProfile$.pipe(
switchMap(profile => {
if (!profile.defaultRuleChainId) {
return of<PendingOverwrite | null>(null);
}
return this.ruleChainService.getRuleChain(profile.defaultRuleChainId.id, { ignoreLoading: true }).pipe(
map(existing => ({
entityId: profileEntityId,
profileName: profile.name,
existingRuleChainName: existing.name,
}))
);
})
);
}
doInstall(): void {
this.state = 'installing';
const versionId = this.item.id as string;

6
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3811,6 +3811,12 @@
"remove-desc-solution-template": "Removing this solution template will also remove all created entities including dashboards, rule chains, devices, and other associated resources within your tenant",
"remove-anyway": "Remove anyway",
"select-entity-for-cf": "Select entity for calculated field '{{name}}'",
"rule-chain-install-desc": "Installing this will automatically create the rule chain within your tenant. Use 'Set for profile' to also set it as default for Device or Asset profile.",
"rule-chain-install-as-default": "Set for profile",
"select-profile-for-rule-chain": "Select profile for rule chain '{{name}}'",
"rule-chain-overwrite-title": "Replace existing default rule chain?",
"rule-chain-overwrite-body": "Profile <b>{{profile}}</b> currently uses <b>{{existing}}</b> as its default rule chain. Installing will replace it with this rule chain.",
"rule-chain-overwrite-replace": "Replace",
"install": "Install",
"connect": "Connect",
"connect-device": "Connect device",

Loading…
Cancel
Save