Browse Source

Merge branch 'feature/entity-alarm-rules' of github.com:thingsboard/thingsboard into related-entities-aggregation

pull/14280/head
IrynaMatveieva 11 months ago
parent
commit
6e08b7d069
  1. 13
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  2. 25
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  3. 2
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
  4. 13
      application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java
  5. 58
      application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java
  6. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java
  7. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java
  8. 2
      dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java
  9. 26
      dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java
  10. 6
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  11. 2
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java
  12. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java
  13. 8
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java
  14. 13
      ui-ngx/src/app/core/http/calculated-fields.service.ts
  15. 49
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html
  16. 60
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts
  17. 164
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html
  18. 34
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss
  19. 179
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts
  20. 80
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts
  21. 278
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  22. 20
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html
  23. 20
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss
  24. 88
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts
  25. 188
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html
  26. 226
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts
  27. 52
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html
  28. 37
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss
  29. 281
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts
  30. 50
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html
  31. 31
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss
  32. 149
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts
  33. 60
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss
  34. 53
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html
  35. 70
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts
  36. 125
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html
  37. 322
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts
  38. 65
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html
  39. 36
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss
  40. 187
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts
  41. 55
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html
  42. 87
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts
  43. 98
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html
  44. 28
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss
  45. 120
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts
  46. 79
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html
  47. 50
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss
  48. 172
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts
  49. 85
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html
  50. 50
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss
  51. 207
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts
  52. 73
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html
  53. 141
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts
  54. 79
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html
  55. 146
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts
  56. 23
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html
  57. 85
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss
  58. 192
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts
  59. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  60. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts
  61. 23
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  62. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html
  63. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts
  64. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html
  65. 3
      ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts
  66. 6
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  67. 3
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  68. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html
  69. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts
  70. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts
  71. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts
  72. 1
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html
  73. 5
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts
  74. 6
      ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts
  75. 1
      ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts
  76. 5
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  77. 32
      ui-ngx/src/app/modules/home/components/profile/device-profile.component.html
  78. 40
      ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html
  79. 93
      ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html
  80. 94
      ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html
  81. 136
      ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html
  82. 5
      ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts
  83. 89
      ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html
  84. 152
      ui-ngx/src/app/shared/models/alarm-rule.models.ts
  85. 86
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  86. 123
      ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md
  87. 136
      ui-ngx/src/assets/locale/locale.constant-en_US.json

13
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java

@ -27,9 +27,11 @@ import org.thingsboard.server.actors.service.DefaultActorService;
import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor;
import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
@ -74,6 +76,7 @@ import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
@ -320,12 +323,16 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
EntityId fromId = entityRelation.getFrom(); EntityId fromId = entityRelation.getFrom();
String relationType = entityRelation.getType(); String relationType = entityRelation.getType();
if (!(CalculatedField.isSupportedRefEntity(toId) || CalculatedField.isSupportedRefEntity(fromId))) {
callback.onSuccess();
return;
}
MultipleTbCallback callbackForToAndFrom = new MultipleTbCallback(2, callback); MultipleTbCallback callbackForToAndFrom = new MultipleTbCallback(2, callback);
processRelationByDirection(EntitySearchDirection.TO, relationType, toId, callbackForToAndFrom, relationAction.apply(fromId)); processRelationByDirection(EntitySearchDirection.TO, relationType, toId, callbackForToAndFrom, relationAction.apply(fromId));
processRelationByDirection(EntitySearchDirection.FROM, relationType, fromId, callbackForToAndFrom, relationAction.apply(toId)); processRelationByDirection(EntitySearchDirection.FROM, relationType, fromId, callbackForToAndFrom, relationAction.apply(toId));
} }
private void processRelationByDirection(EntitySearchDirection direction, private void processRelationByDirection(EntitySearchDirection direction,
String relationType, String relationType,
EntityId mainId, EntityId mainId,
@ -716,8 +723,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
private EntityId getProfileId(TenantId tenantId, EntityId entityId) { private EntityId getProfileId(TenantId tenantId, EntityId entityId) {
return switch (entityId.getEntityType()) { return switch (entityId.getEntityType()) {
case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); case ASSET -> Optional.ofNullable(assetProfileCache.get(tenantId, (AssetId) entityId)).map(AssetProfile::getId).orElse(null);
case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); case DEVICE -> Optional.ofNullable(deviceProfileCache.get(tenantId, (DeviceId) entityId)).map(DeviceProfile::getId).orElse(null);
default -> null; default -> null;
}; };
} }

25
application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java

@ -24,6 +24,7 @@ import jakarta.annotation.PreDestroy;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
@ -55,6 +57,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.function.Predicate;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION; import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION;
@ -174,7 +177,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
if (!(ctx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config)) { if (!(ctx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config)) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
ListenableFuture<List<EntityId>> relatedEntitiesFut = resolveRelatedEntities(entityId, ctx); ListenableFuture<List<EntityId>> relatedEntitiesFut = resolveRelatedEntities(ctx.getTenantId(), entityId, config.getRelation());
return config.getArguments().entrySet().stream() return config.getArguments().entrySet().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
@ -183,23 +186,24 @@ public abstract class AbstractCalculatedFieldProcessingService {
)); ));
} }
protected ListenableFuture<List<EntityId>> resolveRelatedEntities(EntityId entityId, CalculatedFieldCtx ctx) { protected ListenableFuture<List<EntityId>> resolveRelatedEntities(TenantId tenantId, EntityId entityId, RelationPathLevel relation) {
if (!(ctx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config)) { Predicate<EntityRelation> filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo());
return Futures.immediateFuture(Collections.emptyList()); ListenableFuture<List<EntityRelation>> relationsFut = relationService.findFilteredRelationsByPathQueryAsync(tenantId, new EntityRelationPathQuery(entityId, List.of(relation)), filter);
}
ListenableFuture<List<EntityRelation>> relationsFut = relationService.findByRelationPathQueryAsync(ctx.getTenantId(), new EntityRelationPathQuery(entityId, List.of(config.getRelation())));
return Futures.transform(relationsFut, relations -> { return Futures.transform(relationsFut, relations -> {
if (relations == null) { if (relations == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
return switch (config.getRelation().direction()) { return switch (relation.direction()) {
case FROM -> relations.stream() case FROM -> relations.stream()
.map(EntityRelation::getTo) .map(EntityRelation::getTo)
.toList(); .toList();
case TO -> relations.isEmpty() ? List.of() : List.of(relations.get(0).getFrom()); case TO -> relations.stream()
.map(EntityRelation::getFrom)
.findFirst()
.map(List::of)
.orElseGet(Collections::emptyList);
}; };
}, calculatedFieldCallbackExecutor); }, calculatedFieldCallbackExecutor);
} }
@ -221,7 +225,8 @@ public abstract class AbstractCalculatedFieldProcessingService {
case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId))); case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId)));
case RELATION_PATH_QUERY -> { case RELATION_PATH_QUERY -> {
var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId)), Predicate<EntityRelation> filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo());
yield Futures.transform(relationService.findFilteredRelationsByPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId), filter),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor); configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
} }
}; };

2
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java

@ -162,7 +162,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
} }
private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter, Predicate<CalculatedFieldCtx> linkedEntityFilter, Predicate<CalculatedFieldCtx> dynamicSourceFilter, Predicate<CalculatedFieldCtx> relatedEntityFilter) { private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter, Predicate<CalculatedFieldCtx> linkedEntityFilter, Predicate<CalculatedFieldCtx> dynamicSourceFilter, Predicate<CalculatedFieldCtx> relatedEntityFilter) {
if (!CalculatedField.SUPPORTED_REFERENCED_ENTITIES.contains(entityId.getEntityType())) { if (!CalculatedField.isSupportedRefEntity(entityId)) {
return false; return false;
} }

13
application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java

@ -29,7 +29,6 @@ import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ObjectType;
import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.Tenant;
@ -49,6 +48,7 @@ import org.thingsboard.server.common.data.job.Job;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationRequest;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentials;
@ -274,10 +274,13 @@ public class EntityStateSourcingListener {
@TransactionalEventListener(fallbackExecution = true) @TransactionalEventListener(fallbackExecution = true)
public void handleEvent(RelationActionEvent relationEvent) { public void handleEvent(RelationActionEvent relationEvent) {
if (relationEvent.getActionType() == ActionType.RELATION_ADD_OR_UPDATE) { EntityRelation relation = relationEvent.getRelation();
tbClusterService.onRelationUpdated(relationEvent.getTenantId(), relationEvent.getRelation(), TbQueueCallback.EMPTY); if (CalculatedField.isSupportedRefEntity(relation.getFrom()) && CalculatedField.isSupportedRefEntity(relation.getTo())) {
} else if (relationEvent.getActionType() == ActionType.RELATION_DELETED) { if (relationEvent.getActionType() == ActionType.RELATION_ADD_OR_UPDATE) {
tbClusterService.onRelationDeleted(relationEvent.getTenantId(), relationEvent.getRelation(), TbQueueCallback.EMPTY); tbClusterService.onRelationUpdated(relationEvent.getTenantId(), relation, TbQueueCallback.EMPTY);
} else if (relationEvent.getActionType() == ActionType.RELATION_DELETED) {
tbClusterService.onRelationDeleted(relationEvent.getTenantId(), relation, TbQueueCallback.EMPTY);
}
} }
} }

58
application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java

@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
@ -299,6 +300,45 @@ public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractContr
}); });
} }
@Test
public void testCreateCfAndRelationToRuleChain_checkAggregation() throws Exception {
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
postTelemetry(device3.getId(), "{\"occupied\":true}");
RuleChain ruleChain = new RuleChain();
ruleChain.setName("RuleChain");
ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
postTelemetry(ruleChain.getId(), "{\"occupied\":true}");
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), ruleChain.getId(), "Contains");
createOccupancyCF(asset2.getId());
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "0",
"occupiedSpaces", "1",
"totalSpaces", "1"
));
});
postTelemetry(ruleChain.getId(), "{\"occupied\":true}");
await().alias("update telemetry on rule chain and no aggregation performed").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "0",
"occupiedSpaces", "1",
"totalSpaces", "1"
));
});
}
@Test @Test
public void testDeleteCf_checkNoAggregation() throws Exception { public void testDeleteCf_checkNoAggregation() throws Exception {
CalculatedField cf = createOccupancyCF(asset.getId()); CalculatedField cf = createOccupancyCF(asset.getId());
@ -466,6 +506,24 @@ public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractContr
}); });
} }
@Test
public void testDeleteEntityByRelation_checkAggregation() throws Exception {
createOccupancyCF(asset.getId());
checkInitialCalculation();
doDelete("/api/device/" + device1.getId()).andExpect(status().isOk());
await().alias("create relation and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "0",
"totalSpaces", "1"
));
});
}
@Test @Test
public void testUpdateRelationPath_checkAggregation() throws Exception { public void testUpdateRelationPath_checkAggregation() throws Exception {
CalculatedField cf = createOccupancyCF(asset.getId()); CalculatedField cf = createOccupancyCF(asset.getId());

3
common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java

@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleChainType;
import java.util.List; import java.util.List;
import java.util.function.Predicate;
/** /**
* Created by ashvayka on 27.04.17. * Created by ashvayka on 27.04.17.
@ -86,6 +87,8 @@ public interface RelationService {
ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery); ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
ListenableFuture<List<EntityRelation>> findFilteredRelationsByPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery, Predicate<EntityRelation> relationFilter);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery); List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
// TODO: This method may be useful for some validations in the future // TODO: This method may be useful for some validations in the future

4
common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java

@ -65,6 +65,10 @@ public class CalculatedField extends BaseData<CalculatedFieldId> implements HasN
EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT
)); ));
public static boolean isSupportedRefEntity(EntityId entity) {
return SUPPORTED_REFERENCED_ENTITIES.contains(entity.getEntityType());
}
private TenantId tenantId; private TenantId tenantId;
private EntityId entityId; private EntityId entityId;

2
dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java

@ -47,6 +47,6 @@ public interface CalculatedFieldDao extends Dao<CalculatedField> {
List<CalculatedField> removeAllByEntityId(TenantId tenantId, EntityId entityId); List<CalculatedField> removeAllByEntityId(TenantId tenantId, EntityId entityId);
long countCFByEntityId(TenantId tenantId, EntityId entityId); long countByEntityIdAndTypeNot(TenantId tenantId, EntityId entityId, CalculatedFieldType type);
} }

26
dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java

@ -71,6 +71,7 @@ import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Predicate;
import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber; import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber;
@ -507,6 +508,11 @@ public class BaseRelationService implements RelationService {
@Override @Override
public ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery) { public ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery) {
return findFilteredRelationsByPathQueryAsync(tenantId, relationPathQuery, null);
}
@Override
public ListenableFuture<List<EntityRelation>> findFilteredRelationsByPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery, Predicate<EntityRelation> relationFilter) {
log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery); log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery);
validateId(tenantId, id -> "Invalid tenant id: " + id); validateId(tenantId, id -> "Invalid tenant id: " + id);
validate(relationPathQuery); validate(relationPathQuery);
@ -518,10 +524,24 @@ public class BaseRelationService implements RelationService {
case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
}; };
return Futures.transform(relationsFuture, entityRelations -> entityRelations.size() > limit ? return Futures.transform(relationsFuture, entityRelations -> {
entityRelations.subList(0, limit) : entityRelations, MoreExecutors.directExecutor()); if (entityRelations == null || entityRelations.isEmpty()) {
return Collections.emptyList();
}
List<EntityRelation> relations = relationFilter != null ? filterRelations(entityRelations, relationFilter) : entityRelations;
return relations.size() > limit ? relations.subList(0, limit) : relations;
}, MoreExecutors.directExecutor());
} }
return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit)); return executor.submit(() -> {
List<EntityRelation> entityRelations = relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit);
return relationFilter != null ? filterRelations(entityRelations, relationFilter) : entityRelations;
});
}
private List<EntityRelation> filterRelations(List<EntityRelation> entityRelations, Predicate<EntityRelation> relationFilter) {
return entityRelations.stream()
.filter(relationFilter)
.toList();
} }
@Override @Override

6
dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java

@ -18,6 +18,7 @@ package org.thingsboard.server.dao.service.validator;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
@ -52,11 +53,14 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
@Override @Override
protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) { protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) {
if (calculatedField.getType() == CalculatedFieldType.ALARM) {
return;
}
long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity); long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity);
if (maxCFsPerEntity <= 0) { if (maxCFsPerEntity <= 0) {
return; return;
} }
if (calculatedFieldDao.countCFByEntityId(tenantId, calculatedField.getEntityId()) >= maxCFsPerEntity) { if (calculatedFieldDao.countByEntityIdAndTypeNot(tenantId, calculatedField.getEntityId(), CalculatedFieldType.ALARM) >= maxCFsPerEntity) {
throw new DataValidationException("Calculated fields per entity limit reached!"); throw new DataValidationException("Calculated fields per entity limit reached!");
} }
} }

2
dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java

@ -46,6 +46,6 @@ public interface CalculatedFieldRepository extends JpaRepository<CalculatedField
List<CalculatedFieldEntity> removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); List<CalculatedFieldEntity> removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId);
long countByTenantIdAndEntityId(UUID tenantId, UUID entityId); long countByTenantIdAndEntityIdAndTypeNot(UUID tenantId, UUID entityId, String type);
} }

4
dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java

@ -98,8 +98,8 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao<CalculatedFieldEntity,
} }
@Override @Override
public long countCFByEntityId(TenantId tenantId, EntityId entityId) { public long countByEntityIdAndTypeNot(TenantId tenantId, EntityId entityId, CalculatedFieldType type) {
return calculatedFieldRepository.countByTenantIdAndEntityId(tenantId.getId(), entityId.getId()); return calculatedFieldRepository.countByTenantIdAndEntityIdAndTypeNot(tenantId.getId(), entityId.getId(), type.name());
} }
@Override @Override

8
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java

@ -41,6 +41,9 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_ACK; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_ACK;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_CLEAR; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_CLEAR;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_CREATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_SEVERITY_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED; import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED; import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.CONNECT_EVENT; import static org.thingsboard.server.common.data.msg.TbMsgType.CONNECT_EVENT;
@ -78,7 +81,7 @@ public abstract class AbstractTbMsgPushNode<T extends BaseTbMsgPushNodeConfigura
} }
protected S buildEvent(TbMsg msg, TbContext ctx) { protected S buildEvent(TbMsg msg, TbContext ctx) {
if (msg.isTypeOf(ALARM)) { if (msg.isTypeOneOf(ALARM, ALARM_CREATED, ALARM_UPDATED, ALARM_SEVERITY_UPDATED)) {
EdgeEventActionType actionType = getAlarmActionType(msg); EdgeEventActionType actionType = getAlarmActionType(msg);
return buildEvent(ctx.getTenantId(), actionType, getUUIDFromMsgData(msg), getAlarmEventType(), null); return buildEvent(ctx.getTenantId(), actionType, getUUIDFromMsgData(msg), getAlarmEventType(), null);
} else if (msg.isTypeOneOf(ALARM_ACK, ALARM_CLEAR)) { } else if (msg.isTypeOneOf(ALARM_ACK, ALARM_CLEAR)) {
@ -182,7 +185,8 @@ public abstract class AbstractTbMsgPushNode<T extends BaseTbMsgPushNodeConfigura
protected boolean isSupportedMsgType(TbMsg msg) { protected boolean isSupportedMsgType(TbMsg msg) {
return msg.isTypeOneOf(POST_TELEMETRY_REQUEST, POST_ATTRIBUTES_REQUEST, ATTRIBUTES_UPDATED, ATTRIBUTES_DELETED, TIMESERIES_UPDATED, return msg.isTypeOneOf(POST_TELEMETRY_REQUEST, POST_ATTRIBUTES_REQUEST, ATTRIBUTES_UPDATED, ATTRIBUTES_DELETED, TIMESERIES_UPDATED,
ALARM, ALARM_ACK, ALARM_CLEAR, CONNECT_EVENT, DISCONNECT_EVENT, ACTIVITY_EVENT, INACTIVITY_EVENT, TO_SERVER_RPC_REQUEST); ALARM, ALARM_CREATED, ALARM_UPDATED, ALARM_SEVERITY_UPDATED, ALARM_ACK, ALARM_CLEAR, CONNECT_EVENT,
DISCONNECT_EVENT, ACTIVITY_EVENT, INACTIVITY_EVENT, TO_SERVER_RPC_REQUEST);
} }
} }

13
ui-ngx/src/app/core/http/calculated-fields.service.ts

@ -15,11 +15,15 @@
/// ///
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { CalculatedField, CalculatedFieldTestScriptInputParams } from '@shared/models/calculated-field.models'; import {
CalculatedField,
CalculatedFieldTestScriptInputParams,
CalculatedFieldType
} from '@shared/models/calculated-field.models';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { EntityTestScriptResult } from '@shared/models/entity.models'; import { EntityTestScriptResult } from '@shared/models/entity.models';
@ -46,9 +50,8 @@ export class CalculatedFieldsService {
return this.http.delete<boolean>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config)); return this.http.delete<boolean>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
} }
public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable<PageData<CalculatedField>> { public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, type?: CalculatedFieldType, config?: RequestConfig): Observable<PageData<CalculatedField>> {
return this.http.get<PageData<CalculatedField>>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, return this.http.get<PageData<CalculatedField>>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(type ? {type} : null, config));
defaultHttpOptionsFromConfig(config));
} }
public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable<EntityTestScriptResult> { public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable<EntityTestScriptResult> {

49
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html

@ -0,0 +1,49 @@
<!--
Copyright © 2016-2025 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.
-->
<form style="width: 800px">
<mat-toolbar class="justify-between" color="primary">
<h2>{{ 'alarm-rule.edit-alarm-rule-additional-info' | translate }}</h2>
<button mat-icon-button (click)="cancel()" type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<div class="flex flex-1 flex-col">
<mat-form-field class="mat-block" appearance="outline">
<textarea matInput [formControl]="alarmDetailsControl" rows="5"
placeholder="{{ 'alarm-rule.alarm-rule-additional-info-placeholder' | translate }}"></textarea>
<mat-hint [innerHTML]="'alarm-rule.alarm-rule-additional-info-hint' | translate | safe: 'html'"></mat-hint>
</mat-form-field>
</div>
</div>
<div mat-dialog-actions class="justify-end">
<button mat-button color="primary"
type="button"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
@if (!data.readonly) {
<button mat-raised-button color="primary"
type="button"
(click)="save()"
[disabled]="alarmDetailsControl.invalid || !alarmDetailsControl.dirty">
{{ 'action.save' | translate }}
</button>
}
</div>
</form>

60
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts

@ -0,0 +1,60 @@
///
/// Copyright © 2016-2025 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 } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormControl } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
export interface AlarmRuleDetailsDialogData {
alarmDetails: string;
readonly: boolean;
}
@Component({
selector: 'tb-edit-alarm-details-dialog',
templateUrl: './alarm-rule-details-dialog.component.html',
providers: [],
styleUrls: ['./cf-alarm-rules-dialog.component.scss'],
})
export class AlarmRuleDetailsDialogComponent extends DialogComponent<AlarmRuleDetailsDialogComponent, string> {
alarmDetailsControl: FormControl<string>;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleDetailsDialogData,
public dialogRef: MatDialogRef<AlarmRuleDetailsDialogComponent, string>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.alarmDetailsControl = this.fb.control(this.data.alarmDetails);
if (this.data.readonly) {
this.alarmDetailsControl.disable();
}
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.dialogRef.close(this.alarmDetailsControl.value);
}
}

164
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html

@ -0,0 +1,164 @@
<!--
Copyright © 2016-2025 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.
-->
<div [formGroup]="fieldFormGroup" class="calculated-field-dialog-container flex size-full max-w-4xl flex-col">
<mat-toolbar color="primary">
<h2>{{ 'alarm-rule.alarm-rule' | translate}}</h2>
<span class="flex-1"></span>
<div tb-help="alarmRule"></div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content class="flex-1">
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'common.general' | translate }}</div>
<div class="flex items-center gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'alarm-rule.alarm-type' | translate }}</mat-label>
<input matInput maxlength="255" formControlName="name" required>
@if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) {
<mat-error>
@if (fieldFormGroup.get('name').hasError('required')) {
{{ 'alarm-rule.alarm-type-required' | translate }}
} @else if (fieldFormGroup.get('name').hasError('pattern')) {
{{ 'alarm-rule.alarm-type-pattern' | translate }}
} @else if (fieldFormGroup.get('name').hasError('maxlength')) {
{{ 'alarm-rule.alarm-type-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
[class.mb-5]="fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched"
[entityType]="EntityType.CALCULATED_FIELD"
[additionalActionConfig]="additionalDebugActionConfig"
/>
</div>
</div>
<ng-container formGroupName="configuration">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[ownerId]="data.ownerId"
[entityName]="data.entityName"/>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.create-alarm-rules' | translate }}</div>
<div class="flex flex-1 flex-col">
<tb-create-cf-alarm-rules formControlName="createRules" [arguments]="arguments">
</tb-create-cf-alarm-rules>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.clear-alarm-rule' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-2 pb-2"
[class.!hidden]="!configFormGroup.get('clearRule').value">
<div class="clear-alarm-rule flex flex-1 flex-row">
<tb-cf-alarm-rule formControlName="clearRule" class="flex-1" [arguments]="arguments">
</tb-cf-alarm-rule>
</div>
<button mat-icon-button
class="button-icon"
type="button"
(click)="removeClearAlarmRule()"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
<div *ngIf="!configFormGroup.get('clearRule').value">
<span translate style="margin: 16px 0"
class="tb-prompt flex items-center justify-center">alarm-rule.no-clear-alarm-rule</span>
</div>
<div [class.!hidden]="configFormGroup.get('clearRule').value">
<button mat-stroked-button color="primary"
type="button"
(click)="addClearAlarmRule()"
matTooltip="{{ 'alarm-rule.add-clear-alarm-rule' | translate }}"
matTooltipPosition="above">
<mat-icon class="button-icon">add_circle_outline</mat-icon>
{{ 'alarm-rule.add-clear-alarm-rule' | translate }}
</button>
</div>
</div>
<div class="tb-form-panel no-gap">
<mat-expansion-panel class="tb-settings" [expanded]="false">
<mat-expansion-panel-header>
{{ 'alarm-rule.advanced-settings' | translate }}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide margin" formControlName="propagate">
{{ 'alarm-rule.propagate-alarm' | translate }}
</mat-slide-toggle>
</div>
@if (configFormGroup.get('propagate').value) {
<mat-form-field floatLabel="always" class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>alarm-rule.alarm-rule-relation-types-list</mat-label>
<mat-chip-grid #relationTypesChipList>
<mat-chip-row
*ngFor="let key of configFormGroup.get('propagateRelationTypes').value;"
(removed)="removeRelationType(key)">
{{key}}
<mat-icon matChipRemove>close</mat-icon>
</mat-chip-row>
<input matInput type="text" placeholder="{{'alarm-rule.alarm-rule-relation-types-list' | translate}}"
[matChipInputFor]="relationTypesChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
matChipInputAddOnBlur
(matChipInputTokenEnd)="addRelationType($event)">
</mat-chip-grid>
<mat-hint innerHTML="{{ 'alarm-rule.alarm-rule-relation-types-list-hint' | translate }}"></mat-hint>
</mat-form-field>
}
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide margin" formControlName="propagateToOwner">
{{ 'alarm-rule.propagate-alarm-to-owner' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide margin" formControlName="propagateToTenant">
{{ 'alarm-rule.propagate-alarm-to-tenant' | translate }}
</mat-slide-toggle>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions class="justify-end">
<button mat-button color="primary"
type="button"
cdkFocusInitial
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
(click)="add()"
[disabled]="(isLoading$ | async) || fieldFormGroup.invalid || !fieldFormGroup.dirty">
{{ data.buttonTitle | translate }}
</button>
</div>
</div>

34
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2025 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.
*/
.calculated-field-dialog-container {
width: 869px;
max-width: 100%;
}
.clear-alarm-rule {
border: 1px solid rgba(0, 0, 0, 0.12);
border-left-width: 4px;
border-left-color: green;
border-radius: 4px;
padding: 8px;
min-width: 0;
}
.button-icon {
color: rgba(0, 0, 0, 0.38);
min-width: 40px;
}

179
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts

@ -0,0 +1,179 @@
///
/// Copyright © 2016-2025 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, DestroyRef, Inject, ViewEncapsulation } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { EntityType } from '@shared/models/entity-type.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { COMMA, ENTER, SEMICOLON } from "@angular/cdk/keycodes";
import { MatChipInputEvent } from "@angular/material/chips";
import { AlarmRule, AlarmRuleConditionType, AlarmRuleExpressionType } from "@shared/models/alarm-rule.models";
import { deepTrim } from "@core/utils";
export interface AlarmRuleDialogData {
value?: CalculatedField;
buttonTitle: string;
entityId: EntityId;
tenantId: string;
entityName?: string;
ownerId: EntityId;
additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>;
isDirty?: boolean;
}
@Component({
selector: 'tb-alarm-rule-dialog',
templateUrl: './alarm-rule-dialog.component.html',
styleUrls: ['./alarm-rule-dialog.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogComponent, CalculatedField> {
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.ALARM],
debugSettings: [],
configuration: this.fb.group({
arguments: this.fb.control({}),
propagate: [false],
propagateToOwner: [false],
propagateToTenant: [false],
propagateRelationTypes: [null],
createRules: [null],
clearRule: [null],
}),
});
additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig,
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null;
readonly EntityType = EntityType;
readonly CalculatedFieldType = CalculatedFieldType;
readonly ScriptLanguage = ScriptLanguage;
separatorKeysCodes = [ENTER, COMMA, SEMICOLON];
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData,
protected dialogRef: MatDialogRef<AlarmRuleDialogComponent, CalculatedField>,
private calculatedFieldsService: CalculatedFieldsService,
private destroyRef: DestroyRef,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.observeIsLoading();
this.applyDialogData();
}
get configFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration') as FormGroup;
}
get arguments(): Record<string, CalculatedFieldArgument> {
return this.fieldFormGroup.get('configuration.arguments').value;
}
public removeClearAlarmRule() {
this.configFormGroup.patchValue({clearRule: null});
this.fieldFormGroup.markAsDirty();
}
public addClearAlarmRule() {
const clearAlarmRule: AlarmRule = {
condition: {
type: AlarmRuleConditionType.SIMPLE,
expression: {
type: AlarmRuleExpressionType.SIMPLE
}
}
};
this.configFormGroup.patchValue({clearRule: clearAlarmRule});
}
removeRelationType(key: string): void {
const keys: string[] = this.configFormGroup.get('propagateRelationTypes').value;
const index = keys.indexOf(key);
if (index >= 0) {
keys.splice(index, 1);
this.configFormGroup.get('propagateRelationTypes').setValue(keys);
}
}
addRelationType(event: MatChipInputEvent): void {
const input = event.chipInput.inputElement;
let value = (event.value ?? '').trim();
if (value) {
let keys: string[] = this.configFormGroup.get('propagateRelationTypes').value ?? [];
if (keys.indexOf(value) === -1) {
keys.push(value);
this.configFormGroup.get('propagateRelationTypes').setValue(keys);
}
}
if (input) {
input.value = '';
}
}
get fromGroupValue(): CalculatedField {
return deepTrim(this.fieldFormGroup.value as CalculatedField);
}
cancel(): void {
this.dialogRef.close(null);
}
add(): void {
if (this.fieldFormGroup.valid) {
const alarmRule = { entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue};
alarmRule.configuration.type = CalculatedFieldType.ALARM;
this.calculatedFieldsService.saveCalculatedField(alarmRule)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(calculatedField => this.dialogRef.close(calculatedField));
}
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false});
}
private observeIsLoading(): void {
this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => {
if (loading) {
this.fieldFormGroup.disable({emitEvent: false});
} else {
this.fieldFormGroup.enable({emitEvent: false});
if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty();
}
}
});
}
}

80
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts

@ -0,0 +1,80 @@
///
/// Copyright © 2016-2025 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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import { AlarmRuleDialogComponent } from "@home/components/alarm-rules/alarm-rule-dialog.component";
import { CreateCfAlarmRulesComponent } from "@home/components/alarm-rules/create-cf-alarm-rules.component";
import { CfAlarmRuleComponent } from "@home/components/alarm-rules/cf-alarm-rule.component";
import { CfAlarmRuleConditionComponent } from "@home/components/alarm-rules/cf-alarm-rule-condition.component";
import {
CfAlarmRuleConditionDialogComponent
} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component";
import { CfAlarmScheduleComponent } from "@home/components/alarm-rules/cf-alarm-schedule.component";
import { CfAlarmScheduleDialogComponent } from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component";
import {
EntityDebugSettingsButtonComponent
} from "@home/components/entity/debug/entity-debug-settings-button.component";
import { AlarmRuleFilterTextComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-text.component";
import {
CalculatedFieldArgumentsTableModule
} from "@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module";
import {
AlarmRuleFilterPredicateListComponent
} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component";
import {
AlarmRuleFilterPredicateComponent
} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate.component";
import {
AlarmRuleFilterPredicateValueComponent
} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component";
import {
AlarmRuleComplexFilterPredicateDialogComponent
} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component";
import { AlarmRuleFilterListComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-list.component";
import { AlarmRuleFilterDialogComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component";
import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/alarm-rule-details-dialog.component";
@NgModule({
declarations: [
AlarmRuleDialogComponent,
CreateCfAlarmRulesComponent,
CfAlarmRuleComponent,
CfAlarmRuleConditionComponent,
CfAlarmRuleConditionDialogComponent,
CfAlarmScheduleComponent,
CfAlarmScheduleDialogComponent,
AlarmRuleFilterTextComponent,
AlarmRuleFilterListComponent,
AlarmRuleFilterDialogComponent,
AlarmRuleFilterPredicateListComponent,
AlarmRuleFilterPredicateComponent,
AlarmRuleFilterPredicateValueComponent,
AlarmRuleComplexFilterPredicateDialogComponent,
AlarmRuleDetailsDialogComponent,
],
imports: [
CommonModule,
SharedModule,
EntityDebugSettingsButtonComponent,
CalculatedFieldArgumentsTableModule
],
exports: [
AlarmRuleDialogComponent,
]
})
export class AlarmRuleModule { }

278
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts

@ -0,0 +1,278 @@
///
/// Copyright © 2016-2025 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 {
checkBoxCell,
DateEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { EntityType } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import { Direction } from '@shared/models/page/sort-order';
import { MatDialog } from '@angular/material/dialog';
import { PageLink } from '@shared/models/page/page-link';
import { Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import { EntityId } from '@shared/models/id/entity-id';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { DestroyRef, Renderer2 } from '@angular/core';
import { EntityDebugSettings } from '@shared/models/entity.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { catchError, filter, switchMap } from 'rxjs/operators';
import {
ArgumentEntityType,
CalculatedField,
CalculatedFieldAlarmRule,
CalculatedFieldType,
} from '@shared/models/calculated-field.models';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service';
import { DatePipe } from '@angular/common';
import {
AlarmRuleDialogComponent,
AlarmRuleDialogData
} from "@home/components/alarm-rules/alarm-rule-dialog.component";
import {
CalculatedFieldDebugDialogComponent,
CalculatedFieldDebugDialogData
} from "@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component";
import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.models";
export class AlarmRulesTableConfig extends EntityTableConfig<any> {
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
additionalDebugActionConfig = {
title: this.translate.instant('calculated-fields.see-debug-events'),
action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField),
};
constructor(private calculatedFieldsService: CalculatedFieldsService,
private translate: TranslateService,
private dialog: MatDialog,
private datePipe: DatePipe,
public entityId: EntityId = null,
private store: Store<AppState>,
private destroyRef: DestroyRef,
private renderer: Renderer2,
public entityName: string,
private ownerId: EntityId = null,
private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService,
) {
super();
this.tableTitle = this.translate.instant('alarm-rule.alarm-rules');
this.detailsPanelEnabled = false;
this.pageMode = false;
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = {
type: 'alarm-rule.alarm-rule',
typePlural: 'alarm-rule.alarm-rules',
list: 'alarm-rule.list',
add: 'action.add',
noEntities: 'alarm-rule.no-found',
search: 'action.search',
selectedEntities: 'alarm-rule.selected-fields'
};
this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink);
this.addEntity = this.getCalculatedAlarmDialog.bind(this);
this.deleteEntityTitle = (field: CalculatedField) => this.translate.instant('alarm-rule.delete-title', {title: field.name});
this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text');
this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count});
this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text');
this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id);
this.addActionDescriptors = [
{
name: this.translate.instant('alarm-rule.create'),
icon: 'insert_drive_file',
isEnabled: () => true,
onAction: ($event) => this.getTable().addEntity($event)
},
{
name: this.translate.instant('alarm-rule.import'),
icon: 'file_upload',
isEnabled: () => true,
onAction: () => this.importCalculatedField()
}
];
this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.columns.push(new DateEntityTableColumn<CalculatedFieldAlarmRule>('createdTime', 'common.created-time', this.datePipe, '150px'));
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('name', 'alarm-rule.alarm-type', '33%'));
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('createRule', 'alarm-rule.severities', '67%',
entity => Object.keys(entity.configuration.createRules).map((severity) => this.translate.instant(alarmSeverityTranslations.get(severity as AlarmSeverity))).join(', '),
() => ({}), false));
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('clearRule', 'alarm-rule.cleared', '60px',
entity => checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: '0 14px'}}, false));
this.cellActionDescriptors.push(
{
name: this.translate.instant('action.export'),
icon: 'file_download',
isEnabled: () => true,
onAction: (event$, entity) => this.exportAlarmRule(event$, entity),
},
{
name: this.translate.instant('entity-view.events'),
icon: 'mdi:clipboard-text-clock',
isEnabled: () => true,
onAction: (_, entity) => this.openDebugEventsDialog(entity),
},
{
name: '',
nameFunction: entity => this.entityDebugSettingsService.getDebugConfigLabel(entity?.debugSettings),
icon: 'mdi:bug',
isEnabled: () => true,
iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline',
onAction: ($event, entity) => this.onOpenDebugConfig($event, entity),
},
{
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: (_, entity) => this.editCalculatedField(entity),
}
);
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink, CalculatedFieldType.ALARM);
}
onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void {
const { debugSettings = {}, id } = calculatedField;
const additionalActionConfig = {
...this.additionalDebugActionConfig,
action: () => this.openDebugEventsDialog(calculatedField)
};
if ($event) {
$event.stopPropagation();
}
const { viewContainerRef, renderer } = this.entityDebugSettingsService;
if (!viewContainerRef || !renderer) {
this.entityDebugSettingsService.viewContainerRef = this.getTable().viewContainerRef;
this.entityDebugSettingsService.renderer = this.renderer;
}
this.entityDebugSettingsService.openDebugStrategyPanel({
debugSettings,
debugConfig: {
entityType: EntityType.CALCULATED_FIELD,
entityLabel: 'alarm-rule.alarm-rule',
additionalActionConfig,
},
onSettingsAppliedFn: settings => this.onDebugConfigChanged(id.id, settings)
}, $event.target as Element);
}
private editCalculatedField(calculatedField: CalculatedField, isDirty = false): void {
this.getCalculatedAlarmDialog(calculatedField, 'action.apply', isDirty)
.subscribe((res) => {
if (res) {
this.updateData();
}
});
}
private getCalculatedAlarmDialog(value?: CalculatedField, buttonTitle = 'action.add', isDirty = false): Observable<CalculatedField> {
return this.dialog.open<AlarmRuleDialogComponent, AlarmRuleDialogData, CalculatedField>(AlarmRuleDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
value,
buttonTitle,
entityId: this.entityId,
tenantId: this.tenantId,
entityName: this.entityName,
ownerId: this.ownerId,
additionalDebugActionConfig: this.additionalDebugActionConfig,
isDirty,
},
enterAnimationDuration: isDirty ? 0 : null,
})
.afterClosed()
.pipe(filter(Boolean));
}
private openDebugEventsDialog(calculatedField: CalculatedField): void {
this.dialog.open<CalculatedFieldDebugDialogComponent, CalculatedFieldDebugDialogData, null>(CalculatedFieldDebugDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
tenantId: this.tenantId,
value: calculatedField,
getTestScriptDialogFn: null,
}
})
.afterClosed()
.subscribe();
}
private exportAlarmRule($event: Event, calculatedField: CalculatedField): void {
if ($event) {
$event.stopPropagation();
}
this.importExportService.exportCalculatedField(calculatedField.id.id);
}
private importCalculatedField(): void {
this.importExportService.openCalculatedFieldImportDialog()
.pipe(
filter(Boolean),
switchMap(calculatedField => this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)),
filter(Boolean),
switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)),
filter(Boolean),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.updateData());
}
private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField {
if (calculatedField.type === CalculatedFieldType.GEOFENCING) {
calculatedField.configuration.zoneGroups = Object.keys(calculatedField.configuration.zoneGroups).reduce((acc, key) => {
const arg = calculatedField.configuration.zoneGroups[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
} else {
calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const arg = calculatedField.configuration.arguments[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
}
return calculatedField;
}
private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void {
this.calculatedFieldsService.getCalculatedFieldById(id).pipe(
switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })),
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef),
).subscribe(() => this.updateData());
}
}

20
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html

@ -0,0 +1,20 @@
<!--
Copyright © 2016-2025 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 (alarmRulesTableConfig) {
<tb-entities-table [entitiesTableConfig]="alarmRulesTableConfig"></tb-entities-table>
}

20
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss

@ -0,0 +1,20 @@
/**
* Copyright © 2016-2025 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 ::ng-deep {
tb-entities-table {
--mat-sidenav-content-background-color: white;
}
}

88
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts

@ -0,0 +1,88 @@
///
/// Copyright © 2016-2025 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 {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
input,
Renderer2,
ViewChild,
} from '@angular/core';
import { EntityId } from '@shared/models/id/entity-id';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service';
import { DatePipe } from '@angular/common';
import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config";
@Component({
selector: 'tb-alarm-rules-table',
templateUrl: './alarm-rules-table.component.html',
styleUrls: ['./alarm-rules-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [EntityDebugSettingsService]
})
export class AlarmRulesTableComponent {
@ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent;
active = input<boolean>();
entityId = input<EntityId>();
entityName = input<string>();
ownerId = input<EntityId>();
alarmRulesTableConfig: AlarmRulesTableConfig;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private translate: TranslateService,
private dialog: MatDialog,
private store: Store<AppState>,
private datePipe: DatePipe,
private cd: ChangeDetectorRef,
private renderer: Renderer2,
private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService,
private destroyRef: DestroyRef) {
effect(() => {
if (this.active()) {
this.alarmRulesTableConfig = new AlarmRulesTableConfig(
this.calculatedFieldsService,
this.translate,
this.dialog,
this.datePipe,
this.entityId(),
this.store,
this.destroyRef,
this.renderer,
this.entityName(),
this.ownerId(),
this.importExportService,
this.entityDebugSettingsService,
);
this.cd.markForCheck();
}
});
}
}

188
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html

@ -0,0 +1,188 @@
<!--
Copyright © 2016-2025 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.
-->
<form [formGroup]="conditionFormGroup" (ngSubmit)="save()">
<mat-toolbar color="primary">
<h2>{{ (readonly ? 'alarm-rule.alarm-rule-condition' : 'alarm-rule.edit-alarm-rule-condition') | translate }}</h2>
<span class="flex-1"></span>
<div formGroupName="expression" class="flex flex-row items-center justify-start gt-xs:gap-4" style="min-width: fit-content;">
<tb-toggle-select formControlName="type" appearance="fill-invert" selectMediaBreakpoint="xs">
<tb-toggle-option [value]="AlarmRuleExpressionType.SIMPLE">{{ 'alarm-rule.expression-type.simple' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="AlarmRuleExpressionType.TBEL">{{ 'alarm-rule.expression-type.tbel' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<div class="tb-form-panel no-padding no-border">
<div class="flex flex-1 flex-col" formGroupName="expression">
@if (conditionFormGroup.get('expression.type').value === AlarmRuleExpressionType.SIMPLE) {
<div class="tb-form-panel">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">{{ 'alarm-rule.argument-filters' | translate }}</div>
<tb-toggle-select formControlName="operation"
selectMediaBreakpoint="xs">
<tb-toggle-option [value]="ComplexOperation.AND">{{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }}</tb-toggle-option>
<tb-toggle-option [value]="ComplexOperation.OR">{{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-alarm-rule-filter-list
[operation]="conditionFormGroup.get('expression.operation').value"
[arguments]="arguments"
formControlName="filters">
</tb-alarm-rule-filter-list>
</div>
} @else {
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ 'alarm-rule.script' | translate }}
</div>
<tb-js-func formControlName="expression"
functionName="expression"
[disableUndefinedCheck]="true"
[scriptLanguage]="scriptLanguage.TBEL"
[functionArgs]="functionArgs"
[highlightRules]="argumentsHighlightRules"
[editorCompleter]="argumentsEditorCompleter"
noValidate="true">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'alarm-rule.expression-type.tbel' | translate }}
</div>
</tb-js-func>
</div>
}
</div>
<section class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.condition-settings' | translate }}</div>
<mat-form-field class="mat-block" hideRequiredMarker appearance="outline" subscriptSizing="dynamic">
<mat-label translate>alarm-rule.condition-type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let alarmConditionType of alarmConditionTypes" [value]="alarmConditionType">
{{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
@if (conditionFormGroup.get('type').value == AlarmConditionType.DURATION) {
<div class="tb-form-panel stroked no-padding-bottom">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">{{ 'alarm-rule.value' | translate }}</div>
<tb-toggle-select [formControl]="durationDynamicModeControl"
selectMediaBreakpoint="xs">
<tb-toggle-option [value]="false">{{ 'alarm-rule.static' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'alarm-rule.dynamic' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="flex flex-row gap-2 xs:flex-col">
<div class="flex-1" [class.!hidden]="durationDynamicModeControl.value">
<ng-container *ngTemplateOutlet="staticValueTemplate; context:{type: AlarmConditionType.DURATION, groupName: 'value'}"></ng-container>
</div>
<div class="flex-1" [class.!hidden]="!durationDynamicModeControl.value">
<ng-container *ngTemplateOutlet="dynamicValueTemplate; context:{groupName: 'value'}"></ng-container>
</div>
<div class="max-w-23% flex-full xs:max-w-full">
<mat-form-field class="mat-block" appearance="outline">
<mat-select formControlName="unit" required
placeholder="{{ 'alarm-rule.condition-duration-time-unit' | translate }}">
<mat-option *ngFor="let timeUnit of timeUnits" [value]="timeUnit">
{{ timeUnitTranslations.get(timeUnit) | translate }}
</mat-option>
</mat-select>
<mat-error *ngIf="conditionFormGroup.get('unit').hasError('required')">
{{ 'alarm-rule.condition-duration-time-unit-required' | translate }}
</mat-error>
</mat-form-field>
</div>
</div>
</div>
} @else if (conditionFormGroup.get('type').value == AlarmConditionType.REPEATING) {
<div class="tb-form-panel stroked">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">{{ 'alarm-rule.value' | translate }}</div>
<tb-toggle-select [formControl]="repeatingDynamicModeControl" selectMediaBreakpoint="xs">
<tb-toggle-option [value]="false">{{ 'alarm-rule.static' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'alarm-rule.dynamic' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="flex flex-1 flex-row gap-2">
<div class="flex-1" [class.!hidden]="repeatingDynamicModeControl.value">
<ng-container *ngTemplateOutlet="staticValueTemplate; context:{type: AlarmConditionType.REPEATING, groupName: 'count'}"></ng-container>
</div>
<div class="flex-1" [class.!hidden]="!repeatingDynamicModeControl.value">
<ng-container *ngTemplateOutlet="dynamicValueTemplate; context:{groupName: 'count'}"></ng-container>
</div>
</div>
</div>
}
</section>
</div>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
(click)="cancel()" cdkFocusInitial>
{{ (readonly ? 'action.close' : 'action.cancel') | translate }}
</button>
@if (!readonly) {
<button mat-raised-button color="primary"
*ngIf="!readonly"
type="submit"
[disabled]="conditionFormGroup.invalid || !conditionFormGroup.dirty">
{{ 'action.save' | translate }}
</button>
}
</div>
<ng-template #staticValueTemplate let-type="type" let-groupName="groupName">
<div class="flex flex-1 flex-row items-center gap-2" [formGroupName]="groupName">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input type="number" matInput
step="1" min="1" max="2147483647"
formControlName="staticValue">
<mat-label>{{ defaultValuePlaceholder | translate }}</mat-label>
@if (conditionFormGroup.get(groupName).get('staticValue').hasError('required')) {
<mat-error>{{ defaultValueRequiredError | translate }}</mat-error>
} @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('min')) {
<mat-error>{{ defaultValueRangeError | translate }}</mat-error>
} @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('max')) {
<mat-error>{{ defaultValueRangeError | translate }}</mat-error>
} @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('pattern')) {
<mat-error>{{ defaultValuePatternError | translate }}</mat-error>
}
</mat-form-field>
</div>
</ng-template>
<ng-template #dynamicValueTemplate let-type="type" let-groupName="groupName">
<ng-container [formGroupName]="groupName">
<mat-form-field class="flex flex-1" appearance="outline">
<mat-label translate>alarm-rule.value-argument</mat-label>
<mat-select formControlName="dynamicValueArgument" placeholder="{{ 'action.set' | translate }}">
@for (argument of argumentsList; track argument) {
<mat-option [value]="argument">{{ argument }}</mat-option>
}
</mat-select>
<mat-error *ngIf="conditionFormGroup.get(groupName).get('dynamicValueArgument').hasError('required')">
{{ 'calculated-fields.hint.argument-name-required' | translate }}
</mat-error>
</mat-form-field>
</ng-container>
</ng-template>
</form>

226
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts

@ -0,0 +1,226 @@
///
/// Copyright © 2016-2025 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 } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from "@shared/models/rule-node.models";
import {
AlarmRuleCondition,
AlarmRuleConditionType,
AlarmRuleConditionTypeTranslationMap,
AlarmRuleExpressionType
} from "@shared/models/alarm-rule.models";
import {
CalculatedFieldArgument,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights
} from "@shared/models/calculated-field.models";
import { TbEditorCompleter } from "@shared/models/ace/completion.models";
import { AceHighlightRules } from "@shared/models/ace/ace.models";
import { ComplexOperation, complexOperationTranslationMap } from "@shared/models/query/query.models";
export interface CfAlarmRuleConditionDialogData {
readonly: boolean;
condition: AlarmRuleCondition;
arguments?: Record<string, CalculatedFieldArgument>;
}
@Component({
selector: 'tb-cf-alarm-rule-condition-dialog',
templateUrl: './cf-alarm-rule-condition-dialog.component.html',
providers: [],
styleUrls: ['./cf-alarm-rules-dialog.component.scss'],
})
export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarmRuleConditionDialogComponent, AlarmRuleCondition> {
AlarmRuleExpressionType = AlarmRuleExpressionType;
timeUnits = Object.values(TimeUnit);
timeUnitTranslations = timeUnitTranslationMap;
alarmConditionTypes = Object.values(AlarmRuleConditionType);
AlarmConditionType = AlarmRuleConditionType;
alarmConditionTypeTranslation = AlarmRuleConditionTypeTranslationMap;
readonly = this.data.readonly;
condition = this.data.condition;
conditionFormGroup = this.fb.group({
expression: this.fb.group({
type: [AlarmRuleExpressionType.SIMPLE],
expression: ['', [Validators.required]],
operation: [ComplexOperation.AND],
filters: [null],
}),
type: this.fb.control(AlarmRuleConditionType.SIMPLE, Validators.required),
unit: this.fb.control(TimeUnit.SECONDS, Validators.required),
value: this.fb.group({
staticValue: this.fb.control<number | null>(null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]),
dynamicValueArgument: this.fb.control<string>('', Validators.required),
}),
count: this.fb.group({
staticValue: this.fb.control<number | null>(null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]),
dynamicValueArgument: this.fb.control<string>('', Validators.required),
}),
});
readonly scriptLanguage = ScriptLanguage;
defaultValuePlaceholder = '';
defaultValueRequiredError = '';
defaultValueRangeError = '';
defaultValuePatternError = '';
durationDynamicModeControl = this.fb.control<boolean>(false);
repeatingDynamicModeControl = this.fb.control<boolean>(false);
ComplexOperation = ComplexOperation;
complexOperationTranslationMap = complexOperationTranslationMap;
functionArgs: Array<string>;
argumentsEditorCompleter: TbEditorCompleter;
argumentsHighlightRules: AceHighlightRules;
arguments = this.data.arguments;
argumentsList: Array<string>;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CfAlarmRuleConditionDialogData,
public dialogRef: MatDialogRef<CfAlarmRuleConditionDialogComponent, AlarmRuleCondition>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)];
this.argumentsEditorCompleter = getCalculatedFieldArgumentsEditorCompleter(this.data.arguments);
this.argumentsHighlightRules = getCalculatedFieldArgumentsHighlights(this.data.arguments);
this.argumentsList = this.arguments ? Object.keys(this.arguments): [];
this.conditionFormGroup.patchValue({
expression: {
type: this.condition?.expression?.type ?? AlarmRuleExpressionType.SIMPLE,
expression: this.condition?.expression?.expression ?? null,
filters: this.condition?.expression?.filters ?? [],
operation: this.condition?.expression?.operation ?? ComplexOperation.AND
},
type: this.condition?.type ?? AlarmRuleConditionType.SIMPLE,
unit: this.condition?.unit ?? TimeUnit.SECONDS,
value: {
staticValue: this.condition?.value?.staticValue,
dynamicValueArgument: this.condition?.value?.dynamicValueArgument,
},
count: {
staticValue: this.condition?.count?.staticValue ?? null,
dynamicValueArgument: this.condition?.count?.dynamicValueArgument
}
}, {emitEvent: false});
this.durationDynamicModeControl.patchValue(!!this.condition?.value?.dynamicValueArgument, {emitEvent: false});
this.repeatingDynamicModeControl.patchValue(!!this.condition?.count?.dynamicValueArgument, {emitEvent: false});
this.conditionFormGroup.get('type').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((type) => {
this.updateValidators(type, true);
});
this.conditionFormGroup.get('expression.type').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((type) => {
this.updateExpressionTypeValidator(type);
this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE);
});
this.durationDynamicModeControl.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((mode) => {
this.updateStaticValueValidator(AlarmRuleConditionType.DURATION, mode);
});
this.repeatingDynamicModeControl.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((mode) => {
this.updateStaticValueValidator(AlarmRuleConditionType.REPEATING, mode);
});
this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE);
this.updateExpressionTypeValidator(this.condition?.expression?.type ?? 'SIMPLE');
}
updateStaticValueValidator(type: AlarmRuleConditionType, dynamicValue: boolean) {
const control = this.conditionFormGroup.get(type === AlarmRuleConditionType.DURATION ? 'value' : 'count');
if (dynamicValue) {
control.get('staticValue').disable({emitEvent: false});
control.get('dynamicValueArgument').enable({emitEvent: false});
} else {
control.get('staticValue').enable({emitEvent: false});
control.get('dynamicValueArgument').disable({emitEvent: false});
}
}
updateExpressionTypeValidator(type: 'SIMPLE' | 'TBEL') {
if (type === 'SIMPLE') {
this.conditionFormGroup.get(`expression.expression`).disable({emitEvent: false});
this.conditionFormGroup.get(`expression.filters`).enable({emitEvent: false});
} else {
this.conditionFormGroup.get(`expression.expression`).enable({emitEvent: false});
this.conditionFormGroup.get(`expression.filters`).disable({emitEvent: false});
}
}
private updateValidators(type: AlarmRuleConditionType, emitEvent = false) {
switch (type) {
case AlarmRuleConditionType.DURATION:
this.conditionFormGroup.get('unit').enable({emitEvent: false});
this.conditionFormGroup.get('value').enable({emitEvent: false});
this.conditionFormGroup.get('count').disable({emitEvent: false});
this.updateStaticValueValidator(type, this.durationDynamicModeControl.value);
this.defaultValuePlaceholder = 'alarm-rule.condition-duration-value';
this.defaultValueRequiredError = 'alarm-rule.condition-duration-value-required';
this.defaultValueRangeError = 'alarm-rule.condition-duration-value-range';
this.defaultValuePatternError = 'alarm-rule.condition-duration-value-pattern';
break;
case AlarmRuleConditionType.REPEATING:
this.conditionFormGroup.get('count').enable({emitEvent: false});
this.conditionFormGroup.get('value').disable({emitEvent: false});
this.conditionFormGroup.get('unit').disable({emitEvent: false});
this.updateStaticValueValidator(type, this.repeatingDynamicModeControl.value);
this.defaultValuePlaceholder = 'alarm-rule.condition-repeating-value';
this.defaultValueRequiredError = 'alarm-rule.condition-repeating-value-required';
this.defaultValueRangeError = 'alarm-rule.condition-repeating-value-range';
this.defaultValuePatternError = 'alarm-rule.condition-repeating-value-pattern';
break;
case AlarmRuleConditionType.SIMPLE:
this.conditionFormGroup.get('value').disable({emitEvent: false});
this.conditionFormGroup.get('count').disable({emitEvent: false});
this.conditionFormGroup.get('unit').disable({emitEvent: false});
break;
}
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.dialogRef.close(this.conditionFormGroup.value as AlarmRuleCondition);
}
}

52
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html

@ -0,0 +1,52 @@
<!--
Copyright © 2016-2025 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.
-->
<div class="tb-alarm-rule-condition flex flex-col gap-4 min-w-0" [formGroup]="alarmRuleConditionFormGroup">
<div class="tb-form-row column-xs">
<div class="min-w-40 xs:min-w-fit">{{ 'alarm-rule.condition' | translate }}</div>
<button [disabled]="disabled"
type="button"
class="tb-alarm-rule-condition-button"
mat-stroked-button [color]="conditionSet() ? 'primary' : 'warn'"
(click)="openFilterDialog($event)">
<div class="flex items-center gap-2 justify-between">
<tb-alarm-rule-filter-text [alarmRuleExpression]="alarmRuleConditionFormGroup.get('expression').value"
[arguments]="arguments"
class="flex-1"
[nowrap]="true"
[specText]="specText"
required
addFilterPrompt="{{'alarm-rule.enter-alarm-rule-condition-prompt' | translate}}">
</tb-alarm-rule-filter-text>
<mat-icon [color]="conditionSet() ? 'primary' : 'warn'" class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">{{ conditionSet() ? 'edit' : 'add' }}</mat-icon>
</div>
</button>
</div>
<div class="tb-form-row column-xs">
<div class="min-w-40 xs:min-w-fit">{{ 'alarm-rule.schedule-title' | translate }}</div>
<button [disabled]="disabled"
type="button"
class="tb-alarm-rule-condition-button"
mat-stroked-button color="primary"
(click)="openScheduleDialog($event)">
<div class="flex items-center gap-2">
<span class="tb-alarm-rule-condition-label" tbTruncateWithTooltip [innerHTML]="scheduleText"></span>
<mat-icon class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">edit</mat-icon>
</div>
</button>
</div>
</div>

37
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss

@ -0,0 +1,37 @@
/**
* Copyright © 2016-2025 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: 1;
.tb-alarm-rule-condition {
display: flex;
flex: 1;
&-button {
--mat-outlined-button-horizontal-padding: 3px 0px 12px;
display: block;
width: 100%;
}
&-label {
display: block;
text-align: start;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}

281
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts

@ -0,0 +1,281 @@
///
/// Copyright © 2016-2025 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, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormControl,
Validator,
Validators
} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
import {
dayOfWeekTranslations,
getAlarmScheduleRangeText,
utcTimestampToTimeOfDay
} from '@shared/models/device.models';
import { TimeUnit } from '@shared/models/time/time.models';
import {
CfAlarmRuleConditionDialogComponent,
CfAlarmRuleConditionDialogData
} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component";
import {
AlarmRuleCondition,
AlarmRuleConditionType,
AlarmRuleExpressionType,
AlarmRuleSchedule,
AlarmRuleScheduleType
} from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import {
AlarmRuleScheduleDialogData,
CfAlarmScheduleDialogComponent
} from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component";
import { coerceBoolean } from "@shared/decorators/coercion";
@Component({
selector: 'tb-cf-alarm-rule-condition',
templateUrl: './cf-alarm-rule-condition.component.html',
styleUrls: ['./cf-alarm-rule-condition.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CfAlarmRuleConditionComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CfAlarmRuleConditionComponent),
multi: true,
}
]
})
export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Validator {
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
arguments: Record<string, CalculatedFieldArgument>;
alarmRuleConditionFormGroup = this.fb.group({
type: ['SIMPLE'],
expression: [{type: AlarmRuleExpressionType.SIMPLE}, Validators.required],
schedule: [null],
});
specText = '';
scheduleText = '';
private modelValue: AlarmRuleCondition;
private propagateChange = (v: any) => { };
constructor(private dialog: MatDialog,
private fb: FormBuilder,
private cd: ChangeDetectorRef,
private translate: TranslateService) {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.alarmRuleConditionFormGroup.disable({emitEvent: false});
} else {
this.alarmRuleConditionFormGroup.enable({emitEvent: false});
}
}
writeValue(value: AlarmRuleCondition): void {
this.modelValue = value;
this.updateConditionInfo();
}
public conditionSet() {
return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters);
}
public validate(c: UntypedFormControl) {
return this.conditionSet() ? null : {
alarmRuleCondition: {
valid: false,
},
};
}
public openFilterDialog($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<CfAlarmRuleConditionDialogComponent, CfAlarmRuleConditionDialogData,
AlarmRuleCondition>(CfAlarmRuleConditionDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
readonly: this.disabled,
condition: this.disabled ? this.modelValue : deepClone(this.modelValue),
arguments: this.arguments
}
}).afterClosed().subscribe((result) => {
if (result) {
this.modelValue = {...this.modelValue, ...result};
this.updateModel();
this.cd.detectChanges();
}
});
}
private updateConditionInfo() {
this.alarmRuleConditionFormGroup.patchValue(
{
type: this.modelValue?.type,
expression: this.modelValue?.expression,
schedule: this.modelValue?.schedule,
}, {emitEvent: false}
);
this.updateScheduleText();
this.updateSpecText();
}
private updateSpecText() {
this.specText = '';
if (this.modelValue && this.modelValue.type) {
const type = this.modelValue.type;
switch (type) {
case AlarmRuleConditionType.SIMPLE:
break;
case AlarmRuleConditionType.DURATION:
let duringText = '';
switch (this.modelValue.unit) {
case TimeUnit.SECONDS:
duringText = this.translate.instant('timewindow.seconds', {seconds: this.modelValue.value.staticValue});
break;
case TimeUnit.MINUTES:
duringText = this.translate.instant('timewindow.minutes', {minutes: this.modelValue.value.staticValue});
break;
case TimeUnit.HOURS:
duringText = this.translate.instant('timewindow.hours', {hours: this.modelValue.value.staticValue});
break;
case TimeUnit.DAYS:
duringText = this.translate.instant('timewindow.days', {days: this.modelValue.value.staticValue});
break;
}
if (this.modelValue.value.dynamicValueArgument) {
this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', {
attribute: `${this.modelValue.value.dynamicValueArgument}`
});
} else {
this.specText = this.translate.instant('alarm-rule.condition-during', {
during: duringText
});
}
break;
case AlarmRuleConditionType.REPEATING:
if (this.modelValue.count.dynamicValueArgument) {
this.specText = this.translate.instant('alarm-rule.condition-repeat-times-dynamic', {
attribute: `${this.modelValue.count.dynamicValueArgument}`
});
} else {
this.specText = this.translate.instant('alarm-rule.condition-repeat-times',
{count: this.modelValue.count.staticValue});
}
break;
}
}
}
private updateModel() {
this.updateConditionInfo();
this.propagateChange(this.modelValue);
}
public openScheduleDialog($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<CfAlarmScheduleDialogComponent, AlarmRuleScheduleDialogData,
AlarmRuleSchedule>(CfAlarmScheduleDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
readonly: this.disabled,
alarmSchedule: this.disabled ? this.modelValue?.schedule : deepClone(this.modelValue?.schedule),
arguments: this.arguments
}
}).afterClosed().subscribe((result) => {
if (result) {
this.modelValue.schedule = result;
this.updateModel();
this.cd.detectChanges();
}
});
}
private updateScheduleText() {
let schedule = this.modelValue?.schedule;
this.scheduleText = '';
if (isDefinedAndNotNull(schedule)) {
if (schedule.dynamicValueArgument) {
this.scheduleText = this.translate.instant('alarm-rule.value-argument') + ': ' + schedule?.dynamicValueArgument
} else {
switch (schedule.staticValue.type) {
case AlarmRuleScheduleType.ANY_TIME:
this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time');
break;
case AlarmRuleScheduleType.SPECIFIC_TIME:
for (const day of schedule.staticValue.daysOfWeek) {
if (this.scheduleText.length) {
this.scheduleText += ', ';
}
this.scheduleText += this.translate.instant(dayOfWeekTranslations[day - 1]);
}
this.scheduleText += ' <b>' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(schedule.staticValue.startsOn),
utcTimestampToTimeOfDay(schedule.staticValue.endsOn)) + '</b>';
break;
case AlarmRuleScheduleType.CUSTOM:
for (const item of schedule.staticValue.items) {
if (item.enabled) {
if (this.scheduleText.length) {
this.scheduleText += ', ';
}
this.scheduleText += this.translate.instant(dayOfWeekTranslations[item.dayOfWeek - 1]);
this.scheduleText += ' <b>' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(item.startsOn),
utcTimestampToTimeOfDay(item.endsOn)) + '</b>';
}
}
break;
}
}
}
if (!this.scheduleText.length) {
this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time');
}
}
}

50
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html

@ -0,0 +1,50 @@
<!--
Copyright © 2016-2025 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.
-->
<div class="tb-form-panel no-border no-padding" [formGroup]="alarmRuleFormGroup">
<tb-cf-alarm-rule-condition formControlName="condition" [arguments]="arguments">
</tb-cf-alarm-rule-condition>
@if (!disabled || alarmRuleFormGroup.get('dashboardId').value) {
<div class="tb-form-row space-between column-xs">
<div class="min-w-40 xs:min-w-fit" translate>
alarm-rule.alarm-rule-additional-info
</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="alarmDetails" placeholder="{{ 'alarm-rule.alarm-rule-additional-info' | translate }}">
<button type="button"
matSuffix mat-icon-button aria-label="Open in new"
(click)="openEditDetailsDialog($event)">
<mat-icon class="material-icons">open_in_new</mat-icon>
</button>
</mat-form-field>
</div>
}
@if (!disabled || alarmRuleFormGroup.get('dashboardId').value) {
<div class="tb-form-row space-between column-xs">
<div class="min-w-40 xs:min-w-fit" tb-hint-tooltip-icon="{{'alarm-rule.alarm-rule-mobile-dashboard-hint' | translate}}" translate>
alarm-rule.alarm-rule-mobile-dashboard
</div>
<tb-dashboard-autocomplete appearance="outline"
subscriptSizing="dynamic"
inlineField
class="flex-1"
placeholder="{{ 'alarm-rule.alarm-rule-mobile-dashboard' | translate }}"
formControlName="dashboardId">
</tb-dashboard-autocomplete>
</div>
}
</div>

31
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss

@ -0,0 +1,31 @@
/**
* Copyright © 2016-2025 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 {
.tb-alarm-rule-details, .tb-alarm-rule-dashboard {
padding: 4px;
&.title {
opacity: 0.7;
overflow: visible;
}
}
.tb-alarm-rule-details {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
}

149
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts

@ -0,0 +1,149 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormControl,
Validator,
Validators
} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { isDefinedAndNotNull } from '@core/utils';
import { DashboardId } from '@shared/models/id/dashboard-id';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AlarmRule } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import {
AlarmRuleDetailsDialogComponent,
AlarmRuleDetailsDialogData
} from "@home/components/alarm-rules/alarm-rule-details-dialog.component";
import { coerceBoolean } from "@shared/decorators/coercion";
@Component({
selector: 'tb-cf-alarm-rule',
templateUrl: './cf-alarm-rule.component.html',
styleUrls: ['./cf-alarm-rule.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CfAlarmRuleComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CfAlarmRuleComponent),
multi: true,
}
]
})
export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Validator {
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
@coerceBoolean()
required: boolean;
@Input()
arguments: Record<string, CalculatedFieldArgument>;
private modelValue: AlarmRule;
alarmRuleFormGroup = this.fb.group({
condition: this.fb.control({}, Validators.required),
alarmDetails: [null],
dashboardId: [null]
});
private propagateChange = (v: any) => { };
constructor(private dialog: MatDialog,
private fb: FormBuilder,
private destroyRef: DestroyRef) {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
this.alarmRuleFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.alarmRuleFormGroup.disable({emitEvent: false});
} else {
this.alarmRuleFormGroup.enable({emitEvent: false});
}
}
writeValue(value: AlarmRule): void {
this.modelValue = value;
const model = this.modelValue ? {
...this.modelValue,
dashboardId: this.modelValue.dashboardId?.id
} : null;
this.alarmRuleFormGroup.patchValue(model, {emitEvent: false});
}
public openEditDetailsDialog($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<AlarmRuleDetailsDialogComponent, AlarmRuleDetailsDialogData,
string>(AlarmRuleDetailsDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
alarmDetails: this.alarmRuleFormGroup.get('alarmDetails').value,
readonly: this.disabled
}
}).afterClosed().subscribe((alarmDetails) => {
if (isDefinedAndNotNull(alarmDetails)) {
this.alarmRuleFormGroup.patchValue({alarmDetails});
}
});
}
public validate(c: UntypedFormControl) {
return (!this.required && !this.modelValue || this.alarmRuleFormGroup.valid) ? null : {
alarmRule: {
valid: false,
},
};
}
private updateModel() {
const value = this.alarmRuleFormGroup.value;
this.modelValue = {...value, dashboardId: value.dashboardId ? new DashboardId(value.dashboardId) : null} as AlarmRule;
this.propagateChange(this.modelValue);
}
}

60
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss

@ -0,0 +1,60 @@
/**
* Copyright © 2016-2025 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 {
form {
width: 900px;
max-width: 100%;
display: grid;
grid-template-rows: min-content minmax(auto, 1fr) min-content;
}
.tbel-script-lang-chip {
line-height: 20px;
font-size: 14px;
font-weight: 500;
color: white;
border-radius: 100px;
width: 70px;
min-width: 70px;
display: flex;
justify-content: center;
margin-top: 2px;
margin-right: 4px;
}
.tb-js-func {
.ace_tb {
&.ace_calculated-field {
&-ctx {
color: #C52F00;
}
&-args {
color: #185F2A;
}
&-key {
color: #c24c1a;
}
&-time-window, &-values, &-func, &-value, &-ts, &-latestTs {
color: #7214D0;
}
&-start-ts, &-end-ts {
color: #2CAA00;
}
}
}
}
}

53
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html

@ -0,0 +1,53 @@
<!--
Copyright © 2016-2025 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.
-->
<form>
<mat-toolbar color="primary">
<h2>{{ (readonly ? 'alarm-rule.schedule-title' : 'alarm-rule.edit-schedule') | translate }}</h2>
<span class="flex-1"></span>
<tb-toggle-select [formControl]="dynamicModeControl" appearance="fill-invert" selectMediaBreakpoint="xs">
<tb-toggle-option [value]="false">{{ 'alarm-rule.static-schedule' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'alarm-rule.dynamic-schedule' | translate }}</tb-toggle-option>
</tb-toggle-select>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<tb-cf-alarm-schedule [arguments]="arguments"
[dynamicMode]="dynamicModeControl.value"
[formControl]="alarmScheduleControl">
</tb-cf-alarm-schedule>
</div>
<div mat-dialog-actions class="justify-end">
<button mat-button color="primary"
type="button"
(click)="cancel()" cdkFocusInitial>
{{ (readonly ? 'action.close' : 'action.cancel') | translate }}
</button>
@if (!readonly) {
<button mat-raised-button color="primary"
type="button"
(click)="save()"
[disabled]="alarmScheduleControl.invalid || !alarmScheduleControl.dirty">
{{ 'action.save' | translate }}
</button>
}
</div>
</form>

70
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts

@ -0,0 +1,70 @@
///
/// Copyright © 2016-2025 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 } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { AlarmRuleSchedule } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
export interface AlarmRuleScheduleDialogData {
readonly: boolean;
alarmSchedule: AlarmRuleSchedule;
arguments: Record<string, CalculatedFieldArgument>;
}
@Component({
selector: 'tb-cf-alarm-schedule-dialog',
templateUrl: './cf-alarm-schedule-dialog.component.html',
providers: [],
styleUrls: ['./cf-alarm-rules-dialog.component.scss'],
})
export class CfAlarmScheduleDialogComponent extends DialogComponent<CfAlarmScheduleDialogComponent, AlarmRuleSchedule>{
readonly = this.data.readonly;
alarmSchedule = this.data.alarmSchedule;
arguments = this.data.arguments;
alarmScheduleControl = this.fb.control<AlarmRuleSchedule>(null);
dynamicModeControl = this.fb.control(false);
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleScheduleDialogData,
public dialogRef: MatDialogRef<CfAlarmScheduleDialogComponent, AlarmRuleSchedule>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.alarmScheduleControl.patchValue(this.alarmSchedule, {emitEvent: false});
this.dynamicModeControl.patchValue(!!this.alarmSchedule?.dynamicValueArgument, {emitEvent: false});
if (this.readonly) {
this.alarmScheduleControl.disable({emitEvent: false});
this.dynamicModeControl.disable({emitEvent: false});
}
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.dialogRef.close(this.alarmScheduleControl.value);
}
}

125
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html

@ -0,0 +1,125 @@
<!--
Copyright © 2016-2025 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.
-->
<section [formGroup]="alarmScheduleForm" class="flex flex-col">
@if (!dynamicMode) {
<ng-container formGroupName="staticValue">
<mat-form-field class="mat-block" appearance="outline">
<mat-select formControlName="type" required placeholder="{{ 'alarm-rule.schedule-type' | translate }}">
@for (scheduleType of alarmScheduleTypes; track scheduleType) {
<mat-option [value]="scheduleType">{{ alarmScheduleTypeTranslate.get(scheduleType) | translate }}</mat-option>
}
</mat-select>
@if (alarmScheduleForm.get('staticValue.type').hasError('required')) {
<mat-error>{{ 'alarm-rule.schedule-type-required' | translate }}</mat-error>
}
</mat-form-field>
</ng-container>
@if (alarmScheduleForm.get('staticValue.type').value !== alarmScheduleType.ANY_TIME) {
<div class="tb-form-panel no-padding no-border">
<div formGroupName="staticValue">
<tb-timezone-select
appearance="outline"
userTimezoneByDefault
required
formControlName="timezone">
</tb-timezone-select>
@if (alarmScheduleForm.get('staticValue.type').value === alarmScheduleType.SPECIFIC_TIME) {
<section>
<mat-chip-listbox multiple formControlName="daysOfWeek">
<mat-chip-option *ngFor="let day of allDays; let i = index;"
[value]="i+1">
{{ dayOfWeekTranslationsArray[day] | translate }}
</mat-chip-option>
</mat-chip-listbox>
<tb-error class="block mb-2" [error]="alarmScheduleForm.get('staticValue.daysOfWeek').hasError('required')
? ('alarm-rule.schedule-days-of-week-required' | translate) : ''"></tb-error>
<div class="flex flex-row xs:flex-col gt-xs:gap-2">
<div class="flex flex-row gap-2 gt-md:flex-1">
<mat-form-field class="flex-1 sm:min-w-37.5 sm:max-w-37.5 sm:basis-37.5 md:min-w-37.5 md:max-w-37.5 md:basis-37.5" appearance="outline">
<mat-label translate>alarm-rule.schedule-time-from</mat-label>
<mat-datetimepicker-toggle [for]="startTimePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #startTimePicker type="time" openOnFocus="true"></mat-datetimepicker>
<input required matInput formControlName="startsOn" [matDatetimepicker]="startTimePicker">
</mat-form-field>
<mat-form-field class="flex-1 sm:min-w-37.5 sm:max-w-37.5 sm:basis-37.5 md:min-w-37.5 md:max-w-37.5 md:basis-37.5" appearance="outline">
<mat-label translate>alarm-rule.schedule-time-to</mat-label>
<mat-datetimepicker-toggle [for]="endTimePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #endTimePicker type="time" openOnFocus="true"></mat-datetimepicker>
<input required matInput formControlName="endsOn" [matDatetimepicker]="endTimePicker">
</mat-form-field>
</div>
<div class="flex flex-1 items-center justify-center mb-[22px]">
<div class="text-center" [innerHTML]="getSchedulerRangeText(alarmScheduleForm.get('staticValue'))"></div>
</div>
</div>
</section>
}
@if (alarmScheduleForm.get('staticValue.type').value === alarmScheduleType.CUSTOM) {
<section class="flex flex-col gap-4">
<div *ngFor="let day of allDays" class="flex flex-col gap-4" formArrayName="items">
<div class="flex flex-row items-center justify-start gap-2 xs:flex-col xs:items-start xs:justify-center" [formGroupName]="''+day">
<mat-chip-listbox formControlName="enabled">
<mat-chip-option class="w-32" color="primary" (selectionChange)="changeCustomScheduler($event, day)" [value]="true">{{ dayOfWeekTranslationsArray[day] | translate }}</mat-chip-option>
</mat-chip-listbox>
<div class="flex flex-1 flex-row gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>alarm-rule.schedule-time-from</mat-label>
<mat-datetimepicker-toggle [for]="startTimePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #startTimePicker type="time" openOnFocus="true"></mat-datetimepicker>
<input required matInput formControlName="startsOn" [matDatetimepicker]="startTimePicker">
</mat-form-field>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>alarm-rule.schedule-time-to</mat-label>
<mat-datetimepicker-toggle [for]="endTimePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #endTimePicker type="time" openOnFocus="true"></mat-datetimepicker>
<input required matInput formControlName="endsOn" [matDatetimepicker]="endTimePicker">
</mat-form-field>
</div>
<div class="flex flex-1 items-center justify-center sm:max-w-[120px] mb-[22px] text-center"
[innerHTML]="getSchedulerRangeText(itemsSchedulerForm.at(day))">
</div>
</div>
</div>
<tb-error class="block" [error]="alarmScheduleForm.get('staticValue.items').hasError('dayOfWeeks')
? ('alarm-rule.schedule-days-of-week-required' | translate) : ''"></tb-error>
</section>
}
</div>
</div>
}
} @else {
<div class="flex flex-row items-center gap-2">
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>alarm-rule.value-argument</mat-label>
<mat-select formControlName="dynamicValueArgument" placeholder="{{ 'action.set' | translate }}">
@for (argument of argumentsList; track argument) {
<mat-option [value]="argument">{{ argument }}</mat-option>
}
</mat-select>
@if (alarmScheduleForm.get('dynamicValueArgument').hasError('required')) {
<mat-error>{{ 'calculated-fields.hint.argument-name-required' | translate }}</mat-error>
}
</mat-form-field>
<div matSuffix class="h-14"
[tb-help-popup]="'alarm-rule/alarm_rule_schedule_format'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
</div>
}
</section>

322
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts

@ -0,0 +1,322 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormBuilder,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {
CustomTimeSchedulerItem,
dayOfWeekTranslations,
getAlarmScheduleRangeText,
timeOfDayToUTCTimestamp,
utcTimestampToTimeOfDay
} from '@shared/models/device.models';
import { isDefined } from '@core/utils';
import { getDefaultTimezone } from '@shared/models/time/time.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AlarmRuleSchedule,
AlarmRuleScheduleType,
AlarmRuleScheduleTypeTranslationMap
} from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { MatChipSelectionChange } from "@angular/material/chips";
import { coerceBoolean } from "@shared/decorators/coercion";
@Component({
selector: 'tb-cf-alarm-schedule',
templateUrl: './cf-alarm-schedule.component.html',
styleUrls: [],
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CfAlarmScheduleComponent),
multi: true
}, {
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CfAlarmScheduleComponent),
multi: true
}]
})
export class CfAlarmScheduleComponent implements ControlValueAccessor, Validator, OnInit, OnChanges {
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
arguments: Record<string, CalculatedFieldArgument>;
@Input()
@coerceBoolean()
dynamicMode: boolean;
alarmScheduleForm = this.fb.group({
staticValue: this.fb.group({
type: [AlarmRuleScheduleType.ANY_TIME, Validators.required],
timezone: ['', Validators.required],
daysOfWeek: this.fb.control<number[] | null>(null, Validators.required),
startsOn: this.fb.control<Date | number>(0, Validators.required),
endsOn: this.fb.control<Date | number>(0, Validators.required),
items: this.fb.array(Array.from({length: 7}, (value, i) => this.defaultItemsScheduler(i)), this.validateItems),
}),
dynamicValueArgument: ['', Validators.required]
});
alarmScheduleTypes = Object.keys(AlarmRuleScheduleType) as Array<AlarmRuleScheduleType>;
alarmScheduleType = AlarmRuleScheduleType;
alarmScheduleTypeTranslate = AlarmRuleScheduleTypeTranslationMap;
dayOfWeekTranslationsArray = dayOfWeekTranslations;
argumentsList: Array<string>;
allDays = Array(7).fill(0).map((x, i) => i);
private modelValue: AlarmRuleSchedule;
private defaultItems = Array.from({length: 7}, (value, i) => ({
enabled: true,
dayOfWeek: i + 1
})) as CustomTimeSchedulerItem[];
private propagateChange = (v: any) => { };
constructor(private fb: FormBuilder,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.argumentsList = this.arguments ? Object.keys(this.arguments): [];
this.alarmScheduleForm.get('staticValue.type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((type) => {
const defaultTimezone = getDefaultTimezone();
const staticValue = {...this.alarmScheduleForm.get('staticValue').value, type, items: this.defaultItems, timezone: defaultTimezone};
this.alarmScheduleForm.get('staticValue').patchValue(staticValue, {emitEvent: false});
this.alarmScheduleForm.get('dynamicValueArgument').patchValue(null, {emitEvent: false});
this.updateValidators(type);
});
this.alarmScheduleForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
this.alarmScheduleForm.get('staticValue.items').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((items) => {
items.forEach((item, index) => this.disabledSelectedTime(item.enabled, index, false))
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.dynamicMode) {
const dynamicModeChanges = changes.dynamicMode;
if (!dynamicModeChanges.firstChange && dynamicModeChanges.currentValue !== dynamicModeChanges.previousValue) {
this.updateModeValidators(dynamicModeChanges.currentValue);
this.updateModel();
}
}
}
validateItems(control: AbstractControl): ValidationErrors | null {
const items: any[] = control.value;
if (!items || !items.length || !items.find(v => v.enabled === true)) {
return {
dayOfWeeks: true
};
}
return null;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.alarmScheduleForm.disable({emitEvent: false});
} else {
this.updateModeValidators(this.dynamicMode);
}
}
writeValue(value: AlarmRuleSchedule): void {
if (value) {
this.modelValue = value;
if (this.modelValue.dynamicValueArgument) {
this.alarmScheduleForm.get('dynamicValueArgument').patchValue(this.modelValue.dynamicValueArgument, {emitEvent: false});
} else {
switch (this.modelValue.staticValue.type) {
case AlarmRuleScheduleType.SPECIFIC_TIME:
this.alarmScheduleForm.patchValue({
staticValue: {
type: this.modelValue.staticValue.type,
timezone: this.modelValue.staticValue.timezone,
daysOfWeek: this.modelValue.staticValue.daysOfWeek,
startsOn: utcTimestampToTimeOfDay(this.modelValue.staticValue.startsOn),
endsOn: utcTimestampToTimeOfDay(this.modelValue.staticValue.endsOn),
},
}, {emitEvent: false});
break;
case AlarmRuleScheduleType.CUSTOM:
if (this.modelValue?.dynamicValueArgument) {
this.alarmScheduleForm.patchValue({
staticValue: {
type: this.modelValue.staticValue.type,
},
}, {emitEvent: false});
} else if (this.modelValue.staticValue?.items) {
const alarmDays = [];
this.modelValue.staticValue.items
.sort((a, b) => a.dayOfWeek - b.dayOfWeek)
.forEach((item, index) => {
this.disabledSelectedTime(item.enabled, index);
alarmDays.push({
enabled: item.enabled,
startsOn: utcTimestampToTimeOfDay(item.startsOn),
endsOn: utcTimestampToTimeOfDay(item.endsOn)
});
});
this.alarmScheduleForm.patchValue({
staticValue: {
type: this.modelValue.staticValue.type,
timezone: this.modelValue.staticValue.timezone,
items: alarmDays,
},
}, {emitEvent: false});
}
break;
default:
this.alarmScheduleForm.patchValue(this.modelValue || undefined, {emitEvent: false});
}
this.updateValidators(this.modelValue.staticValue.type);
}
this.updateModeValidators(this.dynamicMode);
}
}
validate(control: FormGroup): ValidationErrors | null {
return this.alarmScheduleForm.valid ? null : {
alarmScheduler: {
valid: false
}
};
}
private updateModeValidators(mode: boolean) {
if (mode) {
this.alarmScheduleForm.get('staticValue').disable({emitEvent: false});
this.alarmScheduleForm.get('dynamicValueArgument').enable({emitEvent: false});
} else {
this.alarmScheduleForm.get('staticValue').enable({emitEvent: false});
this.alarmScheduleForm.get('dynamicValueArgument').disable({emitEvent: false});
this.updateValidators(this.alarmScheduleForm.get('staticValue.type').value);
}
}
private updateValidators(type: AlarmRuleScheduleType){
switch (type){
case AlarmRuleScheduleType.ANY_TIME:
this.alarmScheduleForm.get('staticValue.timezone').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.daysOfWeek').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.startsOn').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.endsOn').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.items').disable({emitEvent: false});
break;
case AlarmRuleScheduleType.SPECIFIC_TIME:
this.alarmScheduleForm.get('staticValue.timezone').enable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.daysOfWeek').enable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.startsOn').enable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.endsOn').enable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.items').disable({emitEvent: false});
break;
case AlarmRuleScheduleType.CUSTOM:
this.alarmScheduleForm.get('staticValue.timezone').enable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.daysOfWeek').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.startsOn').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.endsOn').disable({emitEvent: false});
this.alarmScheduleForm.get('staticValue.items').enable({emitEvent: false});
break;
}
}
private updateModel() {
const value = this.alarmScheduleForm.value as AlarmRuleSchedule;
if (!this.dynamicMode) {
if (isDefined(value.staticValue.startsOn) && value.staticValue.startsOn !== 0) {
value.staticValue.startsOn = timeOfDayToUTCTimestamp(value.staticValue.startsOn);
}
if (isDefined(value.staticValue.endsOn) && value.staticValue.endsOn !== 0) {
value.staticValue.endsOn = timeOfDayToUTCTimestamp(value.staticValue.endsOn);
}
if (isDefined(value.staticValue.items)){
value.staticValue.items = this.alarmScheduleForm.getRawValue().staticValue.items as CustomTimeSchedulerItem[];
value.staticValue.items = value.staticValue.items.map((item) => {
return { ...item, startsOn: timeOfDayToUTCTimestamp(item.startsOn), endsOn: timeOfDayToUTCTimestamp(item.endsOn)};
});
}
}
this.modelValue = value;
this.propagateChange(this.modelValue);
}
private defaultItemsScheduler(index: number): FormGroup {
return this.fb.group({
enabled: [true],
dayOfWeek: [index + 1],
startsOn: [0, Validators.required],
endsOn: [0, Validators.required]
});
}
changeCustomScheduler($event: MatChipSelectionChange, index: number) {
const value = $event.selected;
this.disabledSelectedTime(value, index, true);
}
private disabledSelectedTime(enable: boolean, index: number, emitEvent = false) {
if (enable) {
this.itemsSchedulerForm.at(index).get('startsOn').enable({emitEvent: false});
this.itemsSchedulerForm.at(index).get('endsOn').enable({emitEvent});
} else {
this.itemsSchedulerForm.at(index).get('startsOn').disable({emitEvent: false});
this.itemsSchedulerForm.at(index).get('endsOn').disable({emitEvent});
}
}
getSchedulerRangeText(control: FormGroup | AbstractControl): string {
return getAlarmScheduleRangeText(control.get('startsOn').value, control.get('endsOn').value);
}
get itemsSchedulerForm(): FormArray {
return this.alarmScheduleForm.get('staticValue.items') as FormArray;
}
}

65
ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html

@ -0,0 +1,65 @@
<!--
Copyright © 2016-2025 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.
-->
<div class="flex flex-1 flex-col">
@for (createAlarmRuleControl of createAlarmRulesFormArray().controls; track createAlarmRuleControl; let index = $index) {
<div class="flex flex-row items-center justify-start gap-2 pb-2" [formGroup]="createAlarmRuleControl">
<div class="create-alarm-rule flex flex-1 flex-col gap-4"
[style.border-left-color]="AlarmSeverityNotificationColors.get(createAlarmRuleControl.get('severity').value)">
<div class="tb-form-row space-between column-xs">
<div class="min-w-40 xs:min-w-fit" translate>alarm.severity</div>
<mat-form-field appearance="outline" class="flex-1 xs:w-full" subscriptSizing="dynamic">
<mat-select formControlName="severity"
required
placeholder="{{ 'alarm-rule.select-alarm-severity' | translate }}">
<mat-option *ngFor="let alarmSeverity of alarmSeverities" [value]="alarmSeverity"
[disabled]="isDisabledSeverity(alarmSeverityEnum[alarmSeverity], index)">
{{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<tb-cf-alarm-rule formControlName="alarmRule" [arguments]="arguments" required class="flex-1">
</tb-cf-alarm-rule>
</div>
<button *ngIf="!disabled"
class="button-icon"
mat-icon-button
type="button"
(click)="removeCreateAlarmRule(index)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div *ngIf="!createAlarmRulesFormArray().controls.length && !disabled">
<span translate class="flex items-center justify-center tb-prompt required" style="margin: 16px 0">
alarm-rule.add-create-alarm-rule-prompt
</span>
</div>
<div *ngIf="!disabled">
<button mat-stroked-button color="primary"
type="button"
(click)="addCreateAlarmRule()"
matTooltip="{{ 'alarm-rule.add-create-alarm-rule' | translate }}"
matTooltipPosition="above">
{{ 'alarm-rule.add-create-alarm-rule' | translate }}
</button>
</div>
</div>

36
ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2025 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 {
.create-alarm-rule {
border: 1px solid rgba(0, 0, 0, .12);
border-left-width: 4px;
border-radius: 4px;
padding: 8px;
min-width: 0;
}
}
:host ::ng-deep {
.mat-mdc-form-field.severity {
.mat-mdc-form-field-infix {
width: 160px;
}
}
.button-icon {
color: rgba(0, 0, 0, 0.38);
min-width: 40px;
}
}

187
ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts

@ -0,0 +1,187 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormControl,
Validator,
Validators
} from '@angular/forms';
import { AlarmSeverity, alarmSeverityTranslations } from '@shared/models/alarm.models';
import { AlarmRule } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { AlarmSeverityNotificationColors } from "@shared/models/notification.models";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { coerceBoolean } from "@shared/decorators/coercion";
@Component({
selector: 'tb-create-cf-alarm-rules',
templateUrl: './create-cf-alarm-rules.component.html',
styleUrls: ['./create-cf-alarm-rules.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CreateCfAlarmRulesComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CreateCfAlarmRulesComponent),
multi: true,
}
]
})
export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Validator {
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
arguments: Record<string, CalculatedFieldArgument>;
alarmSeverities = Object.keys(AlarmSeverity);
alarmSeverityEnum = AlarmSeverity;
alarmSeverityTranslationMap = alarmSeverityTranslations;
AlarmSeverityNotificationColors = AlarmSeverityNotificationColors;
createAlarmRulesFormGroup = this.fb.group({
createAlarmRules: this.fb.array<{severity: AlarmSeverity, alarmRule: AlarmRule}>([])
});
private usedSeverities: AlarmSeverity[] = [];
private propagateChange = (v: any) => { };
constructor(private fb: FormBuilder,
private destroyRef: DestroyRef) {
this.createAlarmRulesFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateModel());
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
createAlarmRulesFormArray(): UntypedFormArray {
return this.createAlarmRulesFormGroup.get('createAlarmRules') as UntypedFormArray;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.createAlarmRulesFormGroup.disable({emitEvent: false});
} else {
this.createAlarmRulesFormGroup.enable({emitEvent: false});
}
}
writeValue(createAlarmRules: Record<AlarmSeverity, AlarmRule>): void {
const createAlarmRulesControls: Array<AbstractControl> = [];
if (createAlarmRules) {
Object.keys(createAlarmRules).forEach((severity) => {
const createAlarmRule = createAlarmRules[severity];
if (severity === 'empty') {
severity = null;
}
createAlarmRulesControls.push(this.fb.group({
severity: [severity, Validators.required],
alarmRule: [createAlarmRule, Validators.required]
}));
});
}
const formArray = this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray;
formArray.clear();
createAlarmRulesControls.forEach(c => formArray.push(c));
if (this.disabled) {
this.createAlarmRulesFormGroup.disable({emitEvent: false});
} else {
this.createAlarmRulesFormGroup.enable({emitEvent: false});
}
this.updateUsedSeverities();
if (!this.disabled && !this.createAlarmRulesFormGroup.valid) {
this.updateModel();
}
}
public removeCreateAlarmRule(index: number) {
(this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray).removeAt(index);
}
public addCreateAlarmRule() {
const createAlarmRulesArray = this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray;
createAlarmRulesArray.push(this.fb.group({
severity: [this.getFirstUnusedSeverity(), Validators.required],
alarmRule: [null, Validators.required]
}));
this.createAlarmRulesFormGroup.updateValueAndValidity();
if (!this.createAlarmRulesFormGroup.valid) {
this.updateModel();
}
}
private getFirstUnusedSeverity(): AlarmSeverity {
for (const severityKey of Object.keys(AlarmSeverity)) {
const severity = AlarmSeverity[severityKey];
if (this.usedSeverities.indexOf(severity) === -1) {
return severity;
}
}
return null;
}
public validate(c: UntypedFormControl) {
return (this.createAlarmRulesFormGroup.valid) ? null : {
createAlarmRules: {
valid: false,
},
};
}
public isDisabledSeverity(severity: AlarmSeverity, index: number): boolean {
const usedIndex = this.usedSeverities.indexOf(severity);
return usedIndex > -1 && usedIndex !== index;
}
private updateUsedSeverities() {
this.usedSeverities = [];
const value = this.createAlarmRulesFormGroup.get('createAlarmRules').value;
value.forEach((rule, index) => {
this.usedSeverities[index] = AlarmSeverity[rule.severity];
});
}
private updateModel() {
const value = this.createAlarmRulesFormGroup.get('createAlarmRules').value;
const createAlarmRules = {} as Record<AlarmSeverity, AlarmRule>;
value.forEach(v => createAlarmRules[v.severity] = v.alarmRule);
this.updateUsedSeverities();
this.propagateChange(createAlarmRules);
}
}

55
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html

@ -0,0 +1,55 @@
<!--
Copyright © 2016-2025 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.
-->
<form [formGroup]="complexFilterFormGroup" (ngSubmit)="save()" style="width: 1200px;">
<mat-toolbar class="justify-between" color="primary">
<h2 translate>filter.complex-filter</h2>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>filter.operation.operation</mat-label>
<mat-select required formControlName="operation">
<mat-option *ngFor="let operation of complexOperations" [value]="operation">
{{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
<tb-alarm-rule-filter-predicate-list [valueType]="data.valueType"
[arguments]="arguments"
[operation]="complexFilterFormGroup.get('operation').value"
formControlName="predicates">
</tb-alarm-rule-filter-predicate-list>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
(click)="cancel()"
cdkFocusInitial>
{{'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="complexFilterFormGroup.invalid || !complexFilterFormGroup.dirty">
{{ (isAdd ? 'action.add' : 'action.update') | translate }}
</button>
</div>
</form>

87
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts

@ -0,0 +1,87 @@
///
/// Copyright © 2016-2025 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 } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import {
ComplexOperation,
complexOperationTranslationMap,
EntityKeyValueType,
FilterPredicateType
} from '@shared/models/query/query.models';
import { AlarmRuleFilterPredicate, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
export interface AlarmRuleComplexFilterPredicateDialogData {
complexPredicate: ComplexAlarmRuleFilterPredicate;
isAdd: boolean;
valueType: EntityKeyValueType;
arguments: Record<string, CalculatedFieldArgument>;
}
@Component({
selector: 'tb-alarm-rule-complex-filter-predicate-dialog',
templateUrl: './alarm-rule-complex-filter-predicate-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: AlarmRuleComplexFilterPredicateDialogComponent}],
styleUrls: []
})
export class AlarmRuleComplexFilterPredicateDialogComponent extends
DialogComponent<AlarmRuleComplexFilterPredicateDialogComponent, ComplexAlarmRuleFilterPredicate> {
complexFilterFormGroup = this.fb.group(
{
operation: [ComplexOperation.AND, [Validators.required]],
predicates: this.fb.control<AlarmRuleFilterPredicate[] | null>(null, Validators.required)
}
);
complexOperations = Object.keys(ComplexOperation);
complexOperationEnum = ComplexOperation;
complexOperationTranslations = complexOperationTranslationMap;
isAdd: boolean;
arguments = this.data.arguments;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleComplexFilterPredicateDialogData,
public dialogRef: MatDialogRef<AlarmRuleComplexFilterPredicateDialogComponent, ComplexAlarmRuleFilterPredicate>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.isAdd = this.data.isAdd;
this.complexFilterFormGroup.patchValue(this.data.complexPredicate, {emitEvent: false});
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
const predicate = this.complexFilterFormGroup.value as ComplexAlarmRuleFilterPredicate;
predicate.type = FilterPredicateType.COMPLEX;
this.dialogRef.close(predicate);
}
}

98
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html

@ -0,0 +1,98 @@
<!--
Copyright © 2016-2025 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.
-->
<form [formGroup]="filterFormGroup" (ngSubmit)="save()" style="width: 1200px;">
<mat-toolbar class="justify-between" color="primary">
<h2>{{(data.isAdd ? 'alarm-rule.add-filter' : 'alarm-rule.edit-filter') | translate}}</h2>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<section class="tb-form-panel no-border no-padding">
<section class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.general' | translate }}</div>
<div class="flex flex-row gap-2 xs:flex-col">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>alarm-rule.value-argument</mat-label>
<mat-select formControlName="argument" placeholder="{{ 'action.set' | translate }}">
@for (argument of argumentsList; track argument) {
<mat-option [value]="argument" [disabled]="argumentInUse(argument)" >{{ argument }}</mat-option>
}
</mat-select>
@if (filterFormGroup.get('argument').touched && filterFormGroup.get('argument').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate"
class="tb-error !block">
warning
</mat-icon>
}
</mat-form-field>
<mat-form-field class="flex-1 tb-value-type" appearance="outline" subscriptSizing="dynamic" hideRequiredMarker>
<mat-label translate>filter.value-type.value-type</mat-label>
<mat-select formControlName="valueType">
<mat-select-trigger>
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(filterFormGroup.get('valueType').value)?.icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(filterFormGroup.get('valueType').value)?.name | translate }}</span>
</mat-select-trigger>
<mat-option *ngFor="let valueType of entityKeyValueTypesKeys" [value]="valueType">
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }}</span>
</mat-option>
</mat-select>
<mat-error *ngIf="filterFormGroup.get('valueType').hasError('required')">
{{ 'filter.value-type-required' | translate }}
</mat-error>
</mat-form-field>
</div>
</section>
<section class="tb-form-panel">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">{{ 'alarm-rule.filter' | translate }}</div>
<tb-toggle-select formControlName="operation"
selectMediaBreakpoint="xs">
<tb-toggle-option [value]="ComplexOperation.AND">{{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }}</tb-toggle-option>
<tb-toggle-option [value]="ComplexOperation.OR">{{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-alarm-rule-filter-predicate-list [valueType]="filterFormGroup.get('valueType').value"
[operation]="filterFormGroup.get('operation').value"
[arguments]="arguments"
formControlName="predicates">
</tb-alarm-rule-filter-predicate-list>
</section>
</section>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
(click)="cancel()"
cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="filterFormGroup.invalid || !filterFormGroup.dirty">
{{ (data.isAdd ? 'action.add' : 'action.update') | translate }}
</button>
</div>
</form>

28
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss

@ -0,0 +1,28 @@
/**
* Copyright © 2016-2025 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 ::ng-deep {
.mat-mdc-form-field.tb-value-type {
mat-select-trigger {
.mat-icon {
vertical-align: middle;
margin-right: 8px;
svg {
vertical-align: initial;
}
}
}
}
}

120
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts

@ -0,0 +1,120 @@
///
/// Copyright © 2016-2025 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, DestroyRef, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import {
ComplexOperation,
complexOperationTranslationMap,
EntityKeyValueType,
entityKeyValueTypesMap
} from '@shared/models/query/query.models';
import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
import { AlarmRuleFilter, AlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { FormControlsFrom } from "@shared/models/tenant.model";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
export interface AlarmRuleFilterDialogData {
filter: AlarmRuleFilter;
isAdd: boolean;
arguments: Record<string, CalculatedFieldArgument>;
usedArguments: Array<string>;
}
@Component({
selector: 'tb-alarm-rule-filter-dialog',
templateUrl: './alarm-rule-filter-dialog.component.html',
providers: [],
styleUrls: ['./alarm-rule-filter-dialog.component.scss']
})
export class AlarmRuleFilterDialogComponent extends DialogComponent<AlarmRuleFilterDialogComponent, AlarmRuleFilter> {
filterFormGroup: FormGroup<FormControlsFrom<AlarmRuleFilter>>;
entityKeyValueTypesKeys = Object.keys(EntityKeyValueType);
entityKeyValueTypeEnum = EntityKeyValueType;
entityKeyValueTypes = entityKeyValueTypesMap;
complexOperationTranslationMap = complexOperationTranslationMap;
ComplexOperation = ComplexOperation;
arguments = this.data.arguments;
argumentsList: Array<string>;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleFilterDialogData,
public dialogRef: MatDialogRef<AlarmRuleFilterDialogComponent, AlarmRuleFilter>,
private dialogs: DialogService,
private translate: TranslateService,
private fb: FormBuilder,
private destroyRef: DestroyRef) {
super(store, router, dialogRef);
this.argumentsList = this.arguments ? Object.keys(this.arguments) : [];
this.filterFormGroup = this.fb.group(
{
argument: [this.data.filter.argument, [Validators.required]],
valueType: [this.data.filter.valueType ?? EntityKeyValueType.STRING, [Validators.required]],
predicates: [this.data.filter.predicates, [Validators.required]],
operation: [this.data.filter.operation ?? ComplexOperation.AND]
}
);
this.filterFormGroup.get('valueType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((valueType: EntityKeyValueType) => {
const prevValueType: EntityKeyValueType = this.filterFormGroup.value.valueType;
const predicates: AlarmRuleFilterPredicate[] = this.filterFormGroup.get('predicates').value;
if (prevValueType && prevValueType !== valueType) {
if (predicates && predicates.length) {
this.dialogs.confirm(this.translate.instant('filter.key-value-type-change-title'),
this.translate.instant('filter.key-value-type-change-message')).subscribe(
(result) => {
if (result) {
this.filterFormGroup.get('predicates').setValue([]);
} else {
this.filterFormGroup.get('valueType').setValue(prevValueType, {emitEvent: false});
}
}
);
}
}
});
}
argumentInUse(argument: string): boolean {
return this.data.usedArguments.includes(argument);
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.dialogRef.close(this.filterFormGroup.value as AlarmRuleFilter);
}
}

79
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html

@ -0,0 +1,79 @@
<!--
Copyright © 2016-2025 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.
-->
<section class="tb-form-panel no-padding no-gap stroked mb-4" [formGroup]="filterListFormGroup">
<div class="flex flex-row">
<span class="max-w-8% flex-full"></span>
<div class="flex max-w-92% flex-full flex-row items-center justify-start">
<label translate class="filter-title flex-1">alarm-rule.argument-name</label>
<label translate class="filter-title flex-1">alarm-rule.value-type</label>
<span class="min-w-24">&nbsp;</span>
</div>
</div>
<mat-divider></mat-divider>
<div class="filter-list">
@for (filterControl of filtersFormArray.controls; track filterControl; let index = $index) {
<div class="flex flex-row items-stretch justify-start"
formArrayName="filters"
[class.filter-list-divider]="index">
<div class="filters-operation max-w-8% flex-full">
@if ($index) {
<div class="filters-operation-container">
<span class="filters-operation-label">{{ complexOperationTranslationMap.get(operation) | translate }}</span>
</div>
}
</div>
<div class="flex max-w-92% flex-full flex-col">
<div class="flex flex-row items-center justify-start">
<div class="flex-1">{{ filterControl.value?.argument }}</div>
<div class="flex-1">{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.predicates[0]?.type) | translate }}</div>
<button mat-icon-button color="primary"
type="button"
(click)="editFilter(index)"
matTooltip="{{ 'filter.edit-key-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>{{'edit'}}</mat-icon>
</button>
<button mat-icon-button color="primary"
type="button"
(click)="removeFilter(index)"
matTooltip="{{ 'filter.remove-key-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
@if (index) {
<mat-divider></mat-divider>
}
</div>
}
<span [class.!hidden]="!!filtersFormArray.length"
class="no-data-found flex items-center justify-center"
translate>
filter.no-key-filters
</span>
</div>
</section>
<button mat-button mat-raised-button color="primary"
(click)="addFilter()"
type="button"
matTooltip="{{ 'alarm-rule.add-filter' | translate }}"
matTooltipPosition="above">
{{ 'alarm-rule.add-filter' | translate }}
</button>

50
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss

@ -0,0 +1,50 @@
/**
* Copyright © 2016-2025 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 {
.filter-title {
padding: 12px 0;
font-size: 14px;
font-weight: 500;
}
.filter-list {
overflow: auto;
max-height: 300px;
.no-data-found {
height: 50px;
}
&-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
}
.filters-operation {
display: flex;
justify-content: center;
margin-top: -14px;
&-container {
background-color: white;
}
&-label {
font-weight: 500;
color: #00695C;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba(#00695C, 0.32);
background-color: rgba(#00695C, 0.04);
}
}
}

172
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts

@ -0,0 +1,172 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { Observable } from 'rxjs';
import {
ComplexOperation,
complexOperationTranslationMap,
EntityKeyValueType
} from '@shared/models/query/query.models';
import { MatDialog } from '@angular/material/dialog';
import { deepClone } from '@core/utils';
import {
AlarmRuleFilterDialogComponent,
AlarmRuleFilterDialogData
} from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component";
import { AlarmRuleFilter, FilterPredicateTypeTranslationMap } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@Component({
selector: 'tb-alarm-rule-filter-list',
templateUrl: './alarm-rule-filter-list.component.html',
styleUrls: ['./alarm-rule-filter-list.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AlarmRuleFilterListComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AlarmRuleFilterListComponent),
multi: true
}
]
})
export class AlarmRuleFilterListComponent implements ControlValueAccessor, Validator {
@Input()
arguments: Record<string, CalculatedFieldArgument>;
@Input()
operation: ComplexOperation = ComplexOperation.AND;
filterListFormGroup = this.fb.group({
filters: this.fb.array([])
});
complexOperationTranslationMap = complexOperationTranslationMap;
FilterPredicateTypeTranslationMap = FilterPredicateTypeTranslationMap
private propagateChange = (v: any) => { };
constructor(private fb: FormBuilder,
private dialog: MatDialog,
private destroyRef: DestroyRef) {
this.filterListFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateModel());
}
get filtersFormArray(): FormArray {
return this.filterListFormGroup.get('filters') as FormArray;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
validate(): ValidationErrors | null {
return this.filterListFormGroup.valid && this.filterListFormGroup.get('filters').value?.length ? null : {
filterList: {valid: false}
};
}
writeValue(filters: Array<AlarmRuleFilter>): void {
const keyFilterControls: Array<AbstractControl> = [];
if (filters) {
for (const filter of filters) {
keyFilterControls.push(this.fb.control(filter, [Validators.required]));
}
}
this.filtersFormArray.clear();
keyFilterControls.forEach(c => this.filtersFormArray.push(c));
}
public removeFilter(index: number) {
(this.filterListFormGroup.get('filters') as FormArray).removeAt(index);
}
public addFilter() {
const filtersFormArray = this.filterListFormGroup.get('filters') as FormArray;
this.openFilterDialog(null).subscribe(result => {
if (result) {
filtersFormArray.push(this.fb.control(result, [Validators.required]));
}
});
}
public editFilter(index: number) {
const filter: AlarmRuleFilter =
(this.filterListFormGroup.get('filters') as FormArray).at(index).value;
this.openFilterDialog(filter).subscribe(result => {
if (result) {
(this.filterListFormGroup.get('filters') as FormArray).at(index).patchValue(result);
}
});
}
private openFilterDialog(filter?: AlarmRuleFilter): Observable<AlarmRuleFilter> {
const isAdd = !filter;
if (isAdd) {
filter = {
argument: null,
valueType: EntityKeyValueType.STRING,
operation: ComplexOperation.AND,
predicates: []
};
}
return this.dialog.open<AlarmRuleFilterDialogComponent, AlarmRuleFilterDialogData,
AlarmRuleFilter>(AlarmRuleFilterDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
filter: filter ? deepClone(filter) : null,
isAdd,
arguments: this.arguments,
usedArguments: this.getUsedArguments
}
}).afterClosed();
}
get getUsedArguments(): Array<string> {
const filters = this.filterListFormGroup.get('filters').value as Array<AlarmRuleFilter>;
return filters.length ? filters.map((filter: AlarmRuleFilter) => filter.argument) : [];
}
private updateModel() {
const filters = this.filterListFormGroup.value.filters as Array<AlarmRuleFilter>;
this.propagateChange(filters);
}
}

85
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html

@ -0,0 +1,85 @@
<!--
Copyright © 2016-2025 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.
-->
<section class="flex flex-col gap-4" [formGroup]="filterListFormGroup">
<div class="tb-form-panel no-padding no-gap stroked">
<div class="flex flex-row">
<div class="flex flex-full flex-row items-center justify-start gap-2">
<div class="flex flex-1 flex-row gap-4 pl-2">
<label translate class="filter-title max-w-30% flex-full">alarm-rule.operation</label>
<label translate class="filter-title max-w-20% flex-full xs:hidden">alarm-rule.value-source</label>
<label translate class="filter-title max-w-50% flex-full">alarm-rule.value</label>
</div>
<span [class.!hidden]="disabled" class="min-w-10">&nbsp;</span>
</div>
</div>
<mat-divider style="padding-bottom: 5px;"></mat-divider>
<div class="predicate-list">
@for (predicateControl of predicatesFormArray.controls; track predicateControl; let index = $index) {
<div class="flex flex-row items-stretch justify-start relative"
[class.key-filter-list-divider]="$index">
@if (index) {
<div class="filters-operation-container">
<span class="filters-operation-label">{{ complexOperationTranslations.get(operation) | translate }}</span>
</div>
}
<div class="flex flex-full flex-col">
<div class="flex flex-1 flex-row items-center justify-start">
<tb-alarm-rule-filter-predicate
class="flex-1"
[arguments]="arguments"
[valueType]="valueType"
[formControl]="predicateControl">
</tb-alarm-rule-filter-predicate>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removePredicate($index)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</div>
}
<span [class.!hidden]="!!predicatesFormArray.length"
[class.disabled]="disabled"
class="no-data-found flex items-center justify-center" translate>
filter.no-filters
</span>
</div>
</div>
<div class="flex flex-row gap-2">
<button mat-button mat-raised-button color="primary"
[class.!hidden]="disabled"
(click)="addPredicate(false)"
type="button"
matTooltip="{{ 'filter.add-filter' | translate }}"
matTooltipPosition="above">
{{ 'action.add' | translate }}
</button>
<button mat-button mat-raised-button color="primary"
[class.!hidden]="disabled"
(click)="addPredicate(true)"
type="button"
matTooltip="{{ 'filter.add-complex-filter' | translate }}"
matTooltipPosition="above">
{{ 'filter.add-complex' | translate }}
</button>
</div>
</section>

50
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss

@ -0,0 +1,50 @@
/**
* Copyright © 2016-2025 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 {
.filter-title {
padding: 12px 8px;
font-size: 14px;
font-weight: 500;
}
.predicate-list {
.no-data-found {
height: 50px;
}
}
.key-filter-list-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
.filters-operation {
display: flex;
justify-content: center;
margin-top: -14px;
&-container {
position: absolute;
top: -12px;
left: 10px;
background-color: white;
}
&-label {
font-weight: 500;
color: #00695C;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba(#00695C, 0.32);
background-color: rgba(#00695C, 0.04);
}
}
}

207
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts

@ -0,0 +1,207 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import {
BooleanOperation,
ComplexOperation,
complexOperationTranslationMap,
EntityKeyValueType,
entityKeyValueTypeToFilterPredicateType,
FilterPredicateType,
NumericOperation,
StringOperation
} from '@shared/models/query/query.models';
import { MatDialog } from '@angular/material/dialog';
import { map } from 'rxjs/operators';
import {
AlarmRuleComplexFilterPredicateDialogComponent,
AlarmRuleComplexFilterPredicateDialogData
} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component";
import {
AlarmRuleFilterPredicate,
AlarmRulePredicateInfo,
ComplexAlarmRuleFilterPredicate
} from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@Component({
selector: 'tb-alarm-rule-filter-predicate-list',
templateUrl: './alarm-rule-filter-predicate-list.component.html',
styleUrls: ['./alarm-rule-filter-predicate-list.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent),
multi: true
}
]
})
export class AlarmRuleFilterPredicateListComponent implements ControlValueAccessor, Validator {
@Input() disabled: boolean;
@Input() valueType: EntityKeyValueType;
@Input() operation: ComplexOperation = ComplexOperation.AND;
@Input() arguments: Record<string, CalculatedFieldArgument>;
filterListFormGroup = this.fb.group({
predicates: this.fb.array([])
});
valueTypeEnum = EntityKeyValueType;
complexOperationTranslations = complexOperationTranslationMap;
private propagateChange= (v: any) => { };
constructor(private fb: FormBuilder,
private dialog: MatDialog,
private destroyRef: DestroyRef) {
this.filterListFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateModel());
}
get predicatesFormArray(): FormArray {
return this.filterListFormGroup.get('predicates') as FormArray;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.filterListFormGroup.disable({emitEvent: false});
} else {
this.filterListFormGroup.enable({emitEvent: false});
}
}
validate(control: AbstractControl): ValidationErrors | null {
return this.filterListFormGroup.valid ? null : {
filterList: {valid: false}
};
}
writeValue(predicates: Array<AlarmRulePredicateInfo>): void {
const predicateControls: Array<AbstractControl> = [];
if (predicates) {
for (const predicate of predicates) {
predicateControls.push(this.fb.control(predicate, [Validators.required]));
}
}
this.predicatesFormArray.clear();
predicateControls.forEach(predicate => this.predicatesFormArray.push(predicate));
}
public removePredicate(index: number) {
this.predicatesFormArray.removeAt(index);
}
public addPredicate(complex: boolean) {
const predicatesFormArray = this.filterListFormGroup.get('predicates') as FormArray;
const predicate = this.createDefaultFilterPredicate(this.valueType, complex);
let observable: Observable<AlarmRuleFilterPredicate>;
if (complex) {
observable = this.openComplexFilterDialog(predicate as ComplexAlarmRuleFilterPredicate);
} else {
observable = of(predicate);
}
observable.subscribe((result) => {
if (result) {
predicatesFormArray.push(this.fb.control(result, [Validators.required]));
}
});
}
private createDefaultFilterPredicate(valueType: EntityKeyValueType, complex: boolean): AlarmRuleFilterPredicate {
const predicate = {
type: complex ? FilterPredicateType.COMPLEX : entityKeyValueTypeToFilterPredicateType(valueType)
} as AlarmRuleFilterPredicate;
switch (predicate.type) {
case FilterPredicateType.STRING:
predicate.operation = StringOperation.STARTS_WITH;
predicate.value = {
staticValue: ''
};
predicate.ignoreCase = false;
break;
case FilterPredicateType.NUMERIC:
predicate.operation = NumericOperation.EQUAL;
predicate.value = {
staticValue: valueType === EntityKeyValueType.DATE_TIME ? Date.now() : 0
};
break;
case FilterPredicateType.BOOLEAN:
predicate.operation = BooleanOperation.EQUAL;
predicate.value = {
staticValue: false
};
break;
case FilterPredicateType.COMPLEX:
predicate.operation = ComplexOperation.AND;
predicate.predicates = [];
break;
}
return predicate;
}
private openComplexFilterDialog(predicate: ComplexAlarmRuleFilterPredicate): Observable<ComplexAlarmRuleFilterPredicate> {
return this.dialog.open<AlarmRuleComplexFilterPredicateDialogComponent, AlarmRuleComplexFilterPredicateDialogData,
ComplexAlarmRuleFilterPredicate>(AlarmRuleComplexFilterPredicateDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
complexPredicate: predicate as ComplexAlarmRuleFilterPredicate,
valueType: this.valueType,
isAdd: true,
arguments: this.arguments,
}
}).afterClosed().pipe(
map(result => result)
);
}
private updateModel() {
this.propagateChange(this.filterListFormGroup.get('predicates').value);
}
}

73
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html

@ -0,0 +1,73 @@
<!--
Copyright © 2016-2025 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.
-->
<div class="tb-form-panel no-border no-gap no-padding" [formGroup]="filterPredicateValueFormGroup">
<div class="tb-form-row no-padding no-border column-xs">
<mat-form-field hideRequiredMarker class="flex-1 w-full max-w-30% xs:max-w-full" appearance="outline" subscriptSizing="dynamic">
<mat-select [formControl]="dynamicModeControl" placeholder="{{'filter.dynamic-source-type' | translate}}">
<mat-option [value]="false">{{'alarm-rule.static' | translate}}</mat-option>
<mat-option [value]="true">{{'alarm-rule.dynamic' | translate}}</mat-option>
</mat-select>
</mat-form-field>
@if (!dynamicModeControl.value) {
@switch (valueType) {
@case (valueTypeEnum.STRING) {
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="staticValue" placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (valueTypeEnum.NUMERIC) {
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input required type="number" matInput formControlName="staticValue"
placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="staticValue">
{{ (filterPredicateValueFormGroup.get('staticValue').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
}
@case (valueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="staticValue"
class="flex-1"
appearance="outline"
subscriptSizing="dynamic"
fieldClass="flex-1"
dateText="{{ 'filter.date' | translate }}"
required [showLabel]="false"></tb-datetime>
}
}
} @else {
<mat-form-field class="flex-1 w-full" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="dynamicValueArgument" placeholder="{{ 'action.set' | translate }}">
@for (argument of argumentsList; track argument) {
<mat-option [value]="argument">{{ argument }}</mat-option>
}
</mat-select>
@if (filterPredicateValueFormGroup.get('dynamicValueArgument').touched && filterPredicateValueFormGroup.get('dynamicValueArgument').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate"
class="tb-error !block">
warning
</mat-icon>
}
</mat-form-field>
}
</div>
</div>

141
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts

@ -0,0 +1,141 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
ValidatorFn,
Validators
} from '@angular/forms';
import { EntityKeyValueType } from '@shared/models/query/query.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { AlarmRuleValue } from "@shared/models/alarm-rule.models";
import { FormControlsFrom } from "@shared/models/tenant.model";
@Component({
selector: 'tb-alarm-rule-filter-predicate-value',
templateUrl: './alarm-rule-filter-predicate-value.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent),
multi: true
}
]
})
export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit {
@Input()
arguments: Record<string, CalculatedFieldArgument>;
@Input()
valueType: EntityKeyValueType;
valueTypeEnum = EntityKeyValueType;
filterPredicateValueFormGroup: FormGroup<FormControlsFrom<AlarmRuleValue<string | number | boolean>>>;
dynamicModeControl = this.fb.control(false);
argumentsList: Array<string>;
private propagateChange= (v: any) => { };
constructor(private fb: FormBuilder,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.argumentsList = this.arguments ? Object.keys(this.arguments): [];
let defaultValue: string | number | boolean;
let defaultValueValidators: ValidatorFn[];
switch (this.valueType) {
case EntityKeyValueType.STRING:
defaultValue = '';
defaultValueValidators = [];
break;
case EntityKeyValueType.NUMERIC:
defaultValue = 0;
defaultValueValidators = [Validators.required];
break;
case EntityKeyValueType.BOOLEAN:
defaultValue = false;
defaultValueValidators = [];
break;
case EntityKeyValueType.DATE_TIME:
defaultValue = Date.now();
defaultValueValidators = [Validators.required];
break;
}
this.filterPredicateValueFormGroup = this.fb.group({
staticValue: [defaultValue, defaultValueValidators],
dynamicValueArgument: ['', Validators.required]
});
this.filterPredicateValueFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
this.dynamicModeControl.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => this.updateValueModeValidators(value))
}
private updateValueModeValidators(isDynamicMode: boolean): void {
if (isDynamicMode) {
this.filterPredicateValueFormGroup.get('staticValue').disable({emitEvent: false});
this.filterPredicateValueFormGroup.get('dynamicValueArgument').enable({emitEvent: false});
} else {
this.filterPredicateValueFormGroup.get('staticValue').enable({emitEvent: false});
this.filterPredicateValueFormGroup.get('dynamicValueArgument').disable({emitEvent: false});
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
validate(): ValidationErrors | null {
return this.filterPredicateValueFormGroup.valid ? null : {
filterPredicateValue: {valid: false}
};
}
writeValue(predicateValue: AlarmRuleValue<string | number | boolean>): void {
this.filterPredicateValueFormGroup.patchValue(predicateValue, {emitEvent: false});
this.dynamicModeControl.patchValue(!!predicateValue.dynamicValueArgument?.length);
}
private updateModel() {
this.propagateChange(this.filterPredicateValueFormGroup.value);
}
}

79
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html

@ -0,0 +1,79 @@
<!--
Copyright © 2016-2025 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.
-->
<div class="flex flex-1 flex-row items-center justify-start gap-4" [formGroup]="filterPredicateFormGroup">
<div class="tb-form-panel no-border no-padding-right flex-1" style="flex-direction: row">
@switch (type) {
@case (filterPredicateType.STRING) {
<div class="tb-form-row no-padding no-border column-xs w-full max-w-30% xs:max-w-full flex-1/">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
<mat-option *ngFor="let operation of stringOperations" [value]="operation">
{{stringOperationTranslationMap.get(stringOperation[operation]) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-chip-listbox formControlName="ignoreCase" [hideSingleSelectionIndicator]="true">
<mat-chip-option color="primary" [value]="true">{{ 'alarm-rule.ignore-case' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
}
@case (filterPredicateType.NUMERIC) {
<div class="tb-form-row no-border no-padding no-border w-full max-w-30%">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
<mat-option *ngFor="let operation of numericOperations" [value]="operation">
{{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
}
@case (filterPredicateType.BOOLEAN) {
<div class="tb-form-row no-border no-padding no-border w-full max-w-30%">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
<mat-option *ngFor="let operation of booleanOperations" [value]="operation">
{{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
}
@case (filterPredicateType.COMPLEX) {
<div class="tb-form-row no-border no-padding flex-1">
<button type="button" style="--mat-outlined-button-horizontal-padding: 3px 0px 12px;"
class="block w-full"
mat-stroked-button color="primary"
(click)="openComplexFilterDialog()">
<div class="flex items-center gap-2">
<span class="w-full text-start" translate>filter.complex-filter</span>
<mat-icon class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">edit</mat-icon>
</div>
</button>
</div>
}
}
@if (type !== filterPredicateType.COMPLEX) {
<tb-alarm-rule-filter-predicate-value class="flex-full"
[arguments]="arguments"
[valueType]="valueType"
formControlName="value">
</tb-alarm-rule-filter-predicate-value>
}
</div>
</div>

146
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts

@ -0,0 +1,146 @@
///
/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator
} from '@angular/forms';
import {
BooleanOperation,
booleanOperationTranslationMap,
EntityKeyValueType,
FilterPredicateType,
NumericOperation,
numericOperationTranslationMap,
StringOperation,
stringOperationTranslationMap
} from '@shared/models/query/query.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AlarmRuleFilterPredicate, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models";
import { MatDialog } from "@angular/material/dialog";
import {
AlarmRuleComplexFilterPredicateDialogComponent,
AlarmRuleComplexFilterPredicateDialogData
} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
@Component({
selector: 'tb-alarm-rule-filter-predicate',
templateUrl: './alarm-rule-filter-predicate.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent),
multi: true
}
]
})
export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor, Validator {
@Input()
valueType: EntityKeyValueType;
@Input()
arguments: Record<string, CalculatedFieldArgument>;
filterPredicateFormGroup = this.fb.group({
operation: [],
ignoreCase: false,
predicates: [],
value: []
});
type: FilterPredicateType;
filterPredicateType = FilterPredicateType;
stringOperations = Object.keys(StringOperation);
stringOperation = StringOperation;
stringOperationTranslationMap = stringOperationTranslationMap;
numericOperations = Object.keys(NumericOperation);
numericOperationEnum = NumericOperation;
numericOperationTranslations = numericOperationTranslationMap;
booleanOperations = Object.keys(BooleanOperation);
booleanOperationEnum = BooleanOperation;
booleanOperationTranslations = booleanOperationTranslationMap;
private propagateChange= (v: any) => { };
constructor(private fb: FormBuilder,
private dialog: MatDialog,
private destroyRef: DestroyRef) {
this.filterPredicateFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
validate(): ValidationErrors | null {
return this.filterPredicateFormGroup.valid ? null : {
filterPredicate: {valid: false}
};
}
writeValue(predicate: AlarmRuleFilterPredicate): void {
this.type = predicate.type;
this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false});
}
private updateModel() {
this.propagateChange({type: this.type, ...this.filterPredicateFormGroup.value});
}
public openComplexFilterDialog() {
this.dialog.open<AlarmRuleComplexFilterPredicateDialogComponent, AlarmRuleComplexFilterPredicateDialogData,
ComplexAlarmRuleFilterPredicate>(AlarmRuleComplexFilterPredicateDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
complexPredicate: this.filterPredicateFormGroup.value as ComplexAlarmRuleFilterPredicate,
valueType: this.valueType,
isAdd: false,
arguments: this.arguments,
}
}).afterClosed().subscribe(
(result) => {
if (result) {
this.filterPredicateFormGroup.patchValue(result);
}
}
);
}
}

23
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html

@ -0,0 +1,23 @@
<!--
Copyright © 2016-2025 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.
-->
<div class="tb-filter-text"
tbTruncateWithTooltip
[class.required]="isRequired"
[class.nowrap]="nowrap"
[innerHTML]="filterText">
</div>

85
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss

@ -0,0 +1,85 @@
/**
* Copyright © 2016-2025 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 {
text-overflow: ellipsis;
overflow: hidden;
.tb-filter-text {
overflow-y: auto;
text-align: start;
&.required {
color: #f44336;
padding: 0;
}
&.nowrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
:host ::ng-deep {
.tb-filter-text {
line-height: 1.8em;
span {
display: inline-block;
vertical-align: middle;
line-height: 1.4em;
}
.tb-filter-predicate {
padding-right: 4px;
padding-left: 4px;
}
.tb-filter-entity-key, .tb-filter-value, .tb-filter-dynamic-source {
font-weight: bold;
border: 1px groove rgba(0, 0, 0, .25);
border-radius: 4px;
padding-left: 4px;
padding-right: 4px;
}
.tb-filter-entity-key, .tb-filter-value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.tb-filter-dynamic-source {
}
.tb-filter-entity-key {
color: #305680;
}
.tb-filter-value {
color: #ff5722;
}
.tb-filter-simple-operation {
font-size: 0.9em;
}
.tb-filter-complex-operation {
font-weight: 400;
font-style: italic;
}
.tb-filter-dynamic-value {
.tb-filter-dynamic-source, .tb-filter-value {
color: #0c959c;
}
}
.tb-filter-bracket {
.tb-left-bracket, .tb-right-bracket {
font-size: 1.2em;
}
}
}
}

192
ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts

@ -0,0 +1,192 @@
///
/// Copyright © 2016-2025 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, Input } from '@angular/core';
import {
booleanOperationTranslationMap,
ComplexOperation,
complexOperationTranslationMap,
EntityKeyValueType,
FilterPredicateType,
numericOperationTranslationMap,
stringOperationTranslationMap
} from '@shared/models/query/query.models';
import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import {
AlarmRuleExpression,
AlarmRuleExpressionType,
AlarmRuleFilter,
AlarmRuleFilterPredicate,
ComplexAlarmRuleFilterPredicate
} from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { coerceBoolean } from "@shared/decorators/coercion";
@Component({
selector: 'tb-alarm-rule-filter-text',
templateUrl: './alarm-rule-filter-text.component.html',
styleUrls: ['./alarm-rule-filter-text.component.scss'],
providers: []
})
export class AlarmRuleFilterTextComponent {
@Input()
@coerceBoolean()
required = false;
@Input()
noFilterText = this.translate.instant('filter.no-filter-text');
@Input()
addFilterPrompt = this.translate.instant('filter.add-filter-prompt');
@Input()
@coerceBoolean()
nowrap = false;
@Input()
arguments: Record<string, CalculatedFieldArgument>;
private alarmRuleExpressionValue: AlarmRuleExpression;
get alarmRuleExpression(): AlarmRuleExpression {
return this.alarmRuleExpressionValue;
}
@Input()
set alarmRuleExpression(value: AlarmRuleExpression) {
if (value !== this.alarmRuleExpressionValue) {
this.alarmRuleExpressionValue = value;
this.updateFilterText(value);
}
};
private specTextValue: string;
get specText(): string {
return this.specTextValue;
}
@Input()
set specText(value: string) {
if (value !== this.specTextValue) {
this.specTextValue = value;
this.updateFilterText(this.alarmRuleExpression);
}
}
isRequired = false;
public filterText: string;
constructor(private translate: TranslateService,
private datePipe: DatePipe) {
}
private updateFilterText(value: AlarmRuleExpression) {
this.isRequired = false;
if (value && (value.expression || value.filters)) {
if (value.type === AlarmRuleExpressionType.SIMPLE) {
this.filterText = this.keyFiltersToText(this.translate, this.datePipe, value.filters, value.operation);
} else {
this.filterText = 'function expression(ctx, ' + (this.arguments ? Object.keys(this.arguments).join(', ') : '' ) + ')';
}
if (this.specText?.length) {
this.filterText = this.specText + ': ' + this.filterText;
}
} else {
if (this.required) {
this.filterText = this.addFilterPrompt;
this.isRequired = true;
} else {
this.filterText = this.noFilterText;
}
}
}
private keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array<AlarmRuleFilter>, operation: ComplexOperation): string {
const filtersText = keyFilters.map(keyFilter =>
this.filterPredicateToText(translate, datePipe, keyFilter, keyFilter.predicates));
let result: string;
if (filtersText.length > 1) {
const operationText = translate.instant(complexOperationTranslationMap.get(operation));
result = filtersText.join(' <span class="tb-filter-complex-operation">' + operationText + '</span> ');
} else {
result = filtersText[0];
}
return result;
}
private filterPredicateToText(translate: TranslateService,
datePipe: DatePipe,
keyFilter: AlarmRuleFilter,
keyFilterPredicates: AlarmRuleFilterPredicate[],
complexOperation?: ComplexOperation): string {
const key = keyFilter.argument;
const filterOperation: ComplexOperation = complexOperation ? complexOperation : (keyFilter.operation ?? ComplexOperation.AND);
const predicates = keyFilterPredicates.map((keyFilterPredicate: AlarmRuleFilterPredicate) => {
if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) {
const complexPredicate = keyFilterPredicate as ComplexAlarmRuleFilterPredicate;
const complexOperation = complexPredicate.operation ?? ComplexOperation.AND;
return this.filterPredicateToText(translate, datePipe, keyFilter, complexPredicate.predicates, complexOperation);
} else {
let operation: string;
let value: string;
const val = keyFilterPredicate.value;
const dynamicValue = val?.dynamicValueArgument?.length;
if (dynamicValue) {
value = '<span class="tb-filter-dynamic-value"><span class="tb-filter-value">' + val?.dynamicValueArgument + '</span></span>';
}
switch (keyFilterPredicate.type) {
case FilterPredicateType.STRING:
operation = translate.instant(stringOperationTranslationMap.get(keyFilterPredicate.operation));
if (keyFilterPredicate.ignoreCase) {
operation += ' ' + translate.instant('filter.ignore-case');
}
if (!dynamicValue) {
value = `'${keyFilterPredicate.value.staticValue}'`;
}
break;
case FilterPredicateType.NUMERIC:
operation = translate.instant(numericOperationTranslationMap.get(keyFilterPredicate.operation));
if (!dynamicValue) {
if (keyFilter.valueType === EntityKeyValueType.DATE_TIME) {
value = datePipe.transform(keyFilterPredicate.value.staticValue, 'yyyy-MM-dd HH:mm');
} else {
value = keyFilterPredicate.value.staticValue + '';
}
}
break;
case FilterPredicateType.BOOLEAN:
operation = translate.instant(booleanOperationTranslationMap.get(keyFilterPredicate.operation));
if (!dynamicValue) {
value = translate.instant(keyFilterPredicate.value.staticValue ? 'value.true' : 'value.false');
}
break;
}
if (!dynamicValue) {
value = `<span class="tb-filter-value">${value}</span>`;
}
return `<span class="tb-filter-predicate"><span class="tb-filter-entity-key">${key}</span> <span class="tb-filter-simple-operation">${operation}</span> ${value}</span>`
}
});
if (predicates.length > 1) {
return '(' + predicates.join(` ${translate.instant(complexOperationTranslationMap.get(filterOperation))} `)+ ')';
} else {
return predicates.toString();
}
}
}

4
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts

@ -77,6 +77,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private renderer: Renderer2, private renderer: Renderer2,
public entityName: string, public entityName: string,
private ownerId: EntityId = null,
private importExportService: ImportExportService, private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService, private entityDebugSettingsService: EntityDebugSettingsService,
) { ) {
@ -124,7 +125,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.columns.push(new DateEntityTableColumn<CalculatedField>('createdTime', 'common.created-time', this.datePipe, '150px')); this.columns.push(new DateEntityTableColumn<CalculatedField>('createdTime', 'common.created-time', this.datePipe, '150px'));
this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%')); this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%'));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '170px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)), () => ({whiteSpace: 'nowrap' }))); this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '170px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type).name), () => ({whiteSpace: 'nowrap' })));
this.columns.push(expressionColumn); this.columns.push(expressionColumn);
this.cellActionDescriptors.push( this.cellActionDescriptors.push(
@ -216,6 +217,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
entityId: this.entityId, entityId: this.entityId,
tenantId: this.tenantId, tenantId: this.tenantId,
entityName: this.entityName, entityName: this.entityName,
ownerId: this.ownerId,
additionalDebugActionConfig: this.additionalDebugActionConfig, additionalDebugActionConfig: this.additionalDebugActionConfig,
getTestScriptDialogFn: this.getTestScriptDialog.bind(this), getTestScriptDialogFn: this.getTestScriptDialog.bind(this),
isDirty, isDirty,

2
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts

@ -50,6 +50,7 @@ export class CalculatedFieldsTableComponent {
active = input<boolean>(); active = input<boolean>();
entityId = input<EntityId>(); entityId = input<EntityId>();
entityName = input<string>(); entityName = input<string>();
ownerId = input<EntityId>();
calculatedFieldsTableConfig: CalculatedFieldsTableConfig; calculatedFieldsTableConfig: CalculatedFieldsTableConfig;
@ -76,6 +77,7 @@ export class CalculatedFieldsTableComponent {
this.destroyRef, this.destroyRef,
this.renderer, this.renderer,
this.entityName(), this.entityName(),
this.ownerId(),
this.importExportService, this.importExportService,
this.entityDebugSettingsService, this.entityDebugSettingsService,
); );

23
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts

@ -65,6 +65,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() entityId: EntityId; @Input() entityId: EntityId;
@Input() tenantId: string; @Input() tenantId: string;
@Input() entityName: string; @Input() entityName: string;
@Input() ownerId: EntityId;
@Input() isScript: boolean; @Input() isScript: boolean;
@Input() usedArgumentNames: string[]; @Input() usedArgumentNames: string[];
@Input() isOutputKey = false; @Input() isOutputKey = false;
@ -143,7 +144,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
this.updateEntityFilter(this.entityType, true); this.updateEntityFilter(this.entityType, true);
this.updatedRefEntityIdState(this.entityType); this.updatedRefEntityIdState(this.entityType, false);
this.toggleByEntityKeyType(this.argument.refEntityKey?.type); this.toggleByEntityKeyType(this.argument.refEntityKey?.type);
this.setInitialEntityKeyType(); this.setInitialEntityKeyType();
this.setInitialEntityType(); this.setInitialEntityType();
@ -166,7 +167,9 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
saveArgument(): void { saveArgument(): void {
const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue; const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue;
if (this.entityType === ArgumentEntityType.Tenant) { if (this.entityType === ArgumentEntityType.Owner) {
value.refDynamicSourceConfiguration.type = ArgumentEntityType.Owner;
} else if (this.entityType === ArgumentEntityType.Tenant) {
value.refEntityId = new TenantId(this.tenantId) as any; value.refEntityId = new TenantId(this.tenantId) as any;
} }
if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) { if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) {
@ -185,7 +188,9 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private updatedArgumentType(): void { private updatedArgumentType(): void {
let argumentType = ArgumentEntityType.Current; let argumentType = ArgumentEntityType.Current;
if (this.argument.refEntityId?.entityType) { if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) {
argumentType = ArgumentEntityType.Owner;
} else if (this.argument.refEntityId?.entityType) {
argumentType = this.argument.refEntityId.entityType; argumentType = this.argument.refEntityId.entityType;
} }
this.argumentType.setValue(argumentType, {emitEvent: false}); this.argumentType.setValue(argumentType, {emitEvent: false});
@ -206,6 +211,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
case ArgumentEntityType.Current: case ArgumentEntityType.Current:
entityFilter = this.currentEntityFilter; entityFilter = this.currentEntityFilter;
break; break;
case ArgumentEntityType.Owner:
entityFilter = {
type: AliasFilterType.singleEntity,
singleEntity: this.ownerId
};
break;
case ArgumentEntityType.Tenant: case ArgumentEntityType.Tenant:
entityFilter = { entityFilter = {
type: AliasFilterType.singleEntity, type: AliasFilterType.singleEntity,
@ -303,9 +314,9 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}; };
} }
private updatedRefEntityIdState(type: ArgumentEntityType): void { private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void {
const isEntityWithId = !!type && type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current; const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type);
this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable'](); this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']({emitEvent});
if (!isEntityWithId) { if (!isEntityWithId) {
this.entityNameSubject.next(null); this.entityNameSubject.next(null);
} }

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html

@ -44,6 +44,8 @@
<div tbTruncateWithTooltip> <div tbTruncateWithTooltip>
@if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { @if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) {
{{ 'calculated-fields.argument-current-tenant' | translate }} {{ 'calculated-fields.argument-current-tenant' | translate }}
} @else if (argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) {
{{ 'calculated-fields.argument-owner' | translate }}
} @else if (argument.refEntityId?.id) { } @else if (argument.refEntityId?.id) {
{{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }} {{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }}
} @else { } @else {

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts

@ -85,6 +85,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
@Input() entityId: EntityId; @Input() entityId: EntityId;
@Input() tenantId: string; @Input() tenantId: string;
@Input() entityName: string; @Input() entityName: string;
@Input() ownerId: EntityId;
@Input() isScript: boolean; @Input() isScript: boolean;
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
@ -179,6 +180,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
buttonTitle: isExists ? 'action.apply' : 'action.add', buttonTitle: isExists ? 'action.apply' : 'action.add',
tenantId: this.tenantId, tenantId: this.tenantId,
entityName: this.entityName, entityName: this.entityName,
ownerId: this.ownerId,
usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName),
}; };
this.popoverComponent = this.popoverService.displayPopover({ this.popoverComponent = this.popoverService.displayPopover({

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html

@ -17,7 +17,7 @@
--> -->
<div class="debug-dialog-container flex flex-col xs:h-full"> <div class="debug-dialog-container flex flex-col xs:h-full">
<mat-toolbar color="primary"> <mat-toolbar color="primary">
<h2>{{ 'calculated-fields.debugging' | translate}}</h2> <h2>{{ dialogTitle | translate}}</h2>
<span class="flex-1"></span> <span class="flex-1"></span>
<button mat-icon-button <button mat-icon-button
(click)="cancel()" (click)="cancel()"

3
ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts

@ -47,11 +47,14 @@ export class CalculatedFieldDebugDialogComponent extends DialogComponent<Calcula
readonly debugEventTypes = DebugEventType; readonly debugEventTypes = DebugEventType;
readonly EventType = EventType; readonly EventType = EventType;
dialogTitle: string;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDebugDialogData, @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDebugDialogData,
protected dialogRef: MatDialogRef<CalculatedFieldDebugDialogComponent, string>) { protected dialogRef: MatDialogRef<CalculatedFieldDebugDialogComponent, string>) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.dialogTitle = this.data.value.type === CalculatedFieldType.ALARM ? 'alarm-rule.debugging' : 'calculated-fields.debugging';
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {

6
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html

@ -57,9 +57,12 @@
<mat-label>{{ 'common.type' | translate }}</mat-label> <mat-label>{{ 'common.type' | translate }}</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
@for (type of fieldTypes; track type) { @for (type of fieldTypes; track type) {
<mat-option [value]="type">{{ CalculatedFieldTypeTranslations.get(type) | translate}}</mat-option> <mat-option [value]="type">{{ CalculatedFieldTypeTranslations.get(type).name | translate}}</mat-option>
} }
</mat-select> </mat-select>
@if (CalculatedFieldTypeTranslations.get(fieldFormGroup.get('type').value).hint) {
<mat-hint>{{ CalculatedFieldTypeTranslations.get(fieldFormGroup.get('type').value).hint | translate }}</mat-hint>
}
</mat-form-field> </mat-form-field>
</div> </div>
@switch (fieldFormGroup.get('type').value) { @switch (fieldFormGroup.get('type').value) {
@ -89,6 +92,7 @@
<tb-simple-configuration formControlName="configuration" <tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId" [entityId]="data.entityId"
[entityName]="data.entityName" [entityName]="data.entityName"
[ownerId]="data.ownerId"
[tenantId]="data.tenantId" [tenantId]="data.tenantId"
[isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT" [isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT"
[testScript]="onTestScript.bind(this)" [testScript]="onTestScript.bind(this)"

3
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts

@ -44,6 +44,7 @@ export interface CalculatedFieldDialogData {
entityId: EntityId; entityId: EntityId;
tenantId: string; tenantId: string;
entityName?: string; entityName?: string;
ownerId: EntityId;
additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>;
getTestScriptDialogFn: CalculatedFieldTestScriptFn; getTestScriptDialogFn: CalculatedFieldTestScriptFn;
isDirty?: boolean; isDirty?: boolean;
@ -71,7 +72,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
readonly EntityType = EntityType; readonly EntityType = EntityType;
readonly CalculatedFieldType = CalculatedFieldType; readonly CalculatedFieldType = CalculatedFieldType;
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; readonly fieldTypes = Object.values(CalculatedFieldType).filter(type => type !== CalculatedFieldType.ALARM) as CalculatedFieldType[];
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html

@ -42,6 +42,8 @@
<div tbTruncateWithTooltip> <div tbTruncateWithTooltip>
@if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) { @if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) {
{{ 'calculated-fields.argument-current-tenant' | translate }} {{ 'calculated-fields.argument-current-tenant' | translate }}
} @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) {
{{ 'calculated-fields.argument-owner' | translate }}
} @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) { } @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) {
{{ 'calculated-fields.argument-relation-query' | translate }} {{ 'calculated-fields.argument-relation-query' | translate }}
} @else if (geofenceZone.refEntityId?.id) { } @else if (geofenceZone.refEntityId?.id) {

4
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts

@ -34,7 +34,7 @@ import {
notEmptyObjectValidator, notEmptyObjectValidator,
OutputType OutputType
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -80,7 +80,7 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V
zoneGroups: this.fb.control<Record<string, CalculatedFieldGeofencing>>({}, notEmptyObjectValidator()), zoneGroups: this.fb.control<Record<string, CalculatedFieldGeofencing>>({}, notEmptyObjectValidator()),
scheduledUpdateEnabled: [true], scheduledUpdateEnabled: [true],
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF], scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
output: this.fb.control<CalculatedFieldOutput>({scope: AttributeScope.SERVER_SCOPE, type: OutputType.Timeseries}) output: this.fb.control<CalculatedFieldOutput>({type: OutputType.Timeseries})
}); });
currentEntityFilter: EntityFilter; currentEntityFilter: EntityFilter;

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts

@ -38,7 +38,6 @@ import {
PropagationDirectionTranslations, PropagationDirectionTranslations,
PropagationWithExpression PropagationWithExpression
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@app/shared/models/rule-node.models'; import { ScriptLanguage } from '@app/shared/models/rule-node.models';
@ -83,7 +82,6 @@ export class PropagationConfigurationComponent implements ControlValueAccessor,
}), }),
expression: [calculatedFieldDefaultScript], expression: [calculatedFieldDefaultScript],
output: this.fb.control<CalculatedFieldOutput>({ output: this.fb.control<CalculatedFieldOutput>({
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries, type: OutputType.Timeseries,
}), }),
}); });

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts

@ -36,7 +36,6 @@ import {
OutputType, OutputType,
PropagationDirectionTranslations PropagationDirectionTranslations
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@app/shared/models/rule-node.models'; import { ScriptLanguage } from '@app/shared/models/rule-node.models';
@ -89,7 +88,6 @@ export class RelatedEntitiesAggregationComponentComponent implements ControlValu
metrics: this.fb.control({}, notEmptyObjectValidator()), metrics: this.fb.control({}, notEmptyObjectValidator()),
deduplicationIntervalInSec: [this.minAllowedDeduplicationIntervalInSecForCF], deduplicationIntervalInSec: [this.minAllowedDeduplicationIntervalInSecForCF],
output: this.fb.control<CalculatedFieldOutput>({ output: this.fb.control<CalculatedFieldOutput>({
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries, type: OutputType.Timeseries,
}), }),
useLatestTs: [false] useLatestTs: [false]

1
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html

@ -21,6 +21,7 @@
<tb-calculated-field-arguments-table formControlName="arguments" <tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="entityId" [entityId]="entityId"
[tenantId]="tenantId" [tenantId]="tenantId"
[ownerId]="ownerId"
[entityName]="entityName" [entityName]="entityName"
[isScript]="isScript" /> [isScript]="isScript" />
</div> </div>

5
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts

@ -35,7 +35,6 @@ import {
getCalculatedFieldArgumentsHighlights, getCalculatedFieldArgumentsHighlights,
OutputType OutputType
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { deepClone } from '@core/utils'; import { deepClone } from '@core/utils';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
@ -75,6 +74,9 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
@Input({required: true}) @Input({required: true})
entityName: string; entityName: string;
@Input({required: true})
ownerId: EntityId;
@Input({required: true}) @Input({required: true})
testScript: () => Observable<string>; testScript: () => Observable<string>;
@ -84,7 +86,6 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
expressionSCRIPT: [calculatedFieldDefaultScript], expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.control<CalculatedFieldSimpleOutput>({ output: this.fb.control<CalculatedFieldSimpleOutput>({
name: '', name: '',
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries, type: OutputType.Timeseries,
decimalsByDefault: null decimalsByDefault: null
}), }),

6
ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts

@ -53,6 +53,7 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements
@Input({ transform: booleanAttribute }) failuresEnabled = false; @Input({ transform: booleanAttribute }) failuresEnabled = false;
@Input({ transform: booleanAttribute }) allEnabled = false; @Input({ transform: booleanAttribute }) allEnabled = false;
@Input() entityLabel = '';
@Input() entityType: EntityType; @Input() entityType: EntityType;
@Input() allEnabledUntil = 0; @Input() allEnabledUntil = 0;
@Input() maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE; @Input() maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE;
@ -64,7 +65,6 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements
maxMessagesCount: string; maxMessagesCount: string;
maxTimeFrameDuration: number; maxTimeFrameDuration: number;
initialAllEnabled: boolean; initialAllEnabled: boolean;
entityLabel: string;
isDebugAllActive$ = this.debugAllControl.valueChanges.pipe( isDebugAllActive$ = this.debugAllControl.valueChanges.pipe(
startWith(this.debugAllControl.value), startWith(this.debugAllControl.value),
@ -109,7 +109,9 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements
this.onFailuresControl.patchValue(this.failuresEnabled); this.onFailuresControl.patchValue(this.failuresEnabled);
this.debugAllControl.patchValue(this.allEnabled); this.debugAllControl.patchValue(this.allEnabled);
this.initialAllEnabled = this.allEnabled || this.allEnabledUntil > new Date().getTime(); this.initialAllEnabled = this.allEnabled || this.allEnabledUntil > new Date().getTime();
this.entityLabel = entityTypeTranslations.has(this.entityType) ? entityTypeTranslations.get(this.entityType).type : 'debug-settings.entity'; if (!this.entityLabel) {
this.entityLabel = entityTypeTranslations.has(this.entityType) ? entityTypeTranslations.get(this.entityType).type : 'debug-settings.entity';
}
} }
onCancel(): void { onCancel(): void {

1
ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts

@ -28,6 +28,7 @@ export interface EntityDebugSettingPanelConfig {
maxDebugModeDuration?: number; maxDebugModeDuration?: number;
additionalActionConfig?: AdditionalDebugActionConfig; additionalActionConfig?: AdditionalDebugActionConfig;
entityType: EntityType; entityType: EntityType;
entityLabel?: string;
} }
onSettingsAppliedFn: (settings: EntityDebugSettings) => void; onSettingsAppliedFn: (settings: EntityDebugSettings) => void;
} }

5
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -195,6 +195,8 @@ import {
CalculatedFieldDebugDialogComponent CalculatedFieldDebugDialogComponent
} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; } from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component';
import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module';
import { AlarmRuleModule } from "@home/components/alarm-rules/alarm-rule.module";
import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rules-table.component";
@NgModule({ @NgModule({
declarations: declarations:
@ -209,6 +211,7 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu
AuditLogDetailsDialogComponent, AuditLogDetailsDialogComponent,
CalculatedFieldsTableComponent, CalculatedFieldsTableComponent,
CalculatedFieldDebugDialogComponent, CalculatedFieldDebugDialogComponent,
AlarmRulesTableComponent,
EventContentDialogComponent, EventContentDialogComponent,
EventTableHeaderComponent, EventTableHeaderComponent,
EventTableComponent, EventTableComponent,
@ -351,6 +354,7 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu
SharedModule, SharedModule,
SharedHomeComponentsModule, SharedHomeComponentsModule,
CalculatedFieldsModule, CalculatedFieldsModule,
AlarmRuleModule,
WidgetConfigComponentsModule, WidgetConfigComponentsModule,
BasicWidgetConfigModule, BasicWidgetConfigModule,
Lwm2mProfileComponentsModule, Lwm2mProfileComponentsModule,
@ -369,6 +373,7 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu
EntityDetailsPageComponent, EntityDetailsPageComponent,
AuditLogTableComponent, AuditLogTableComponent,
CalculatedFieldsTableComponent, CalculatedFieldsTableComponent,
AlarmRulesTableComponent,
EventTableComponent, EventTableComponent,
EdgeDownlinkTableHeaderComponent, EdgeDownlinkTableHeaderComponent,
EdgeDownlinkTableComponent, EdgeDownlinkTableComponent,

32
ui-ngx/src/app/modules/home/components/profile/device-profile.component.html

@ -154,21 +154,23 @@
</tb-device-profile-transport-configuration> </tb-device-profile-transport-configuration>
</ng-template> </ng-template>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel [expanded]="false"> @if (entityForm.get('profileData.alarms').value?.length) {
<mat-expansion-panel-header> <mat-expansion-panel [expanded]="false">
<mat-panel-title> <mat-expansion-panel-header>
<div>{{'device-profile.alarm-rules-with-count' | translate: <mat-panel-title>
{count: entityForm.get('profileData.alarms').value ? <div>{{'device-profile.alarm-rules-with-count' | translate:
entityForm.get('profileData.alarms').value.length : 0} }}</div> {count: entityForm.get('profileData.alarms').value ?
</mat-panel-title> entityForm.get('profileData.alarms').value.length : 0} }}</div>
</mat-expansion-panel-header> </mat-panel-title>
<ng-template matExpansionPanelContent> </mat-expansion-panel-header>
<tb-device-profile-alarms <ng-template matExpansionPanelContent>
formControlName="alarms" <tb-device-profile-alarms
[deviceProfileId]="deviceProfileId"> formControlName="alarms"
</tb-device-profile-alarms> [deviceProfileId]="deviceProfileId">
</ng-template> </tb-device-profile-alarms>
</mat-expansion-panel> </ng-template>
</mat-expansion-panel>
}
<mat-expansion-panel [expanded]="true"> <mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>

40
ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html

@ -15,17 +15,29 @@
limitations under the License. limitations under the License.
--> -->
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN && !isEdit" @if (entity && !isEdit) {
label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab"> @if (authUser.authority === authorities.TENANT_ADMIN) {
<tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id" [entityName]="entity.name"/> <mat-tab label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab">
</mat-tab> <tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [ownerId]="entity.tenantId"/>
<mat-tab *ngIf="entity && !isEdit" #auditLogsTab="matTab" </mat-tab>
label="{{ 'audit-log.audit-logs' | translate }}"> }
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table> <mat-tab #alarmRules="matTab" label="{{'alarm-rule.alarm-rules' | translate }}">
</mat-tab> <tb-alarm-rules-table [active]="alarmRules.isActive" [entityId]="entity.id" [entityName]="entity.name" [ownerId]="entity.tenantId"></tb-alarm-rules-table>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN && !isEdit" </mat-tab>
label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab"> <mat-tab #auditLogsTab="matTab"
<tb-version-control detailsMode="true" singleEntityMode="true" label="{{ 'audit-log.audit-logs' | translate }}">
(versionRestored)="entitiesTableConfig.updateData()" <tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table>
[active]="versionControlTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [externalEntityId]="entity.externalId || entity.id"></tb-version-control> </mat-tab>
</mat-tab> @if (authUser.authority === authorities.TENANT_ADMIN) {
<mat-tab label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
<tb-version-control detailsMode="true"
singleEntityMode="true"
(versionRestored)="entitiesTableConfig.updateData()"
[active]="versionControlTab.isActive"
[entityId]="entity.id"
[entityName]="entity.name"
[externalEntityId]="entity.externalId || entity.id">
</tb-version-control>
</mat-tab>
}
}

93
ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html

@ -15,47 +15,52 @@
limitations under the License. limitations under the License.
--> -->
<mat-tab *ngIf="entity" @if (entity) {
label="{{ 'attribute.attributes' | translate }}" #attributesTab="matTab"> <mat-tab label="{{ 'attribute.attributes' | translate }}" #attributesTab="matTab">
<tb-attribute-table [defaultAttributeScope]="attributeScopes.SERVER_SCOPE" <tb-attribute-table [defaultAttributeScope]="attributeScopes.SERVER_SCOPE"
[active]="attributesTab.isActive" [active]="attributesTab.isActive"
[entityId]="entity.id" [entityId]="entity.id"
[entityName]="entity.name"> [entityName]="entity.name">
</tb-attribute-table> </tb-attribute-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity" <mat-tab label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab">
label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab"> <tb-attribute-table [defaultAttributeScope]="latestTelemetryTypes.LATEST_TELEMETRY"
<tb-attribute-table [defaultAttributeScope]="latestTelemetryTypes.LATEST_TELEMETRY" disableAttributeScopeSelection
disableAttributeScopeSelection [active]="telemetryTab.isActive"
[active]="telemetryTab.isActive" [entityId]="entity.id"
[entityId]="entity.id" [entityName]="entity.name">
[entityName]="entity.name"> </tb-attribute-table>
</tb-attribute-table> </mat-tab>
</mat-tab> @if (authUser.authority === authorities.TENANT_ADMIN) {
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" <mat-tab label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab">
label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab"> <tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id" [ownerId]="entity.tenantId"/>
<tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id"/> </mat-tab>
</mat-tab> <mat-tab #alarmRules="matTab"
<mat-tab *ngIf="entity" label="{{'alarm-rule.alarm-rules' | translate }}">
label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab"> <tb-alarm-rules-table [active]="alarmRules.isActive" [entityId]="entity.id" [entityName]="entity.name"></tb-alarm-rules-table>
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table> </mat-tab>
</mat-tab> }
<mat-tab *ngIf="entity" <mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
label="{{ 'asset.events' | translate }}" #eventsTab="matTab"> <tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
<tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id" </mat-tab>
[entityId]="entity.id"></tb-event-table> <mat-tab label="{{ 'asset.events' | translate }}" #eventsTab="matTab">
</mat-tab> <tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id"
<mat-tab *ngIf="entity" [entityId]="entity.id"></tb-event-table>
label="{{ 'relation.relations' | translate }}" #relationsTab="matTab"> </mat-tab>
<tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table> <mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab">
</mat-tab> <tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" </mat-tab>
label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab"> @if (authUser.authority === authorities.TENANT_ADMIN) {
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table> <mat-tab label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab">
</mat-tab> <tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" </mat-tab>
label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab"> <mat-tab label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
<tb-version-control detailsMode="true" singleEntityMode="true" <tb-version-control detailsMode="true" singleEntityMode="true"
(versionRestored)="entitiesTableConfig.updateData()" (versionRestored)="entitiesTableConfig.updateData()"
[active]="versionControlTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [externalEntityId]="entity.externalId || entity.id"></tb-version-control> [active]="versionControlTab.isActive" [entityId]="entity.id"
</mat-tab> [entityName]="entity.name"
[externalEntityId]="entity.externalId || entity.id">
</tb-version-control>
</mat-tab>
}
}

94
ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html

@ -15,43 +15,57 @@
limitations under the License. limitations under the License.
--> -->
<mat-tab *ngIf="entity" @if (entity) {
label="{{ 'attribute.attributes' | translate }}" #attributesTab="matTab"> <mat-tab label="{{ 'attribute.attributes' | translate }}" #attributesTab="matTab">
<tb-attribute-table [defaultAttributeScope]="attributeScopes.SERVER_SCOPE" <tb-attribute-table [defaultAttributeScope]="attributeScopes.SERVER_SCOPE"
[active]="attributesTab.isActive" [active]="attributesTab.isActive"
[entityId]="entity.id" [entityId]="entity.id"
[entityName]="entity.name"> [entityName]="entity.name">
</tb-attribute-table> </tb-attribute-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity" <mat-tab label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab">
label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab"> <tb-attribute-table [defaultAttributeScope]="latestTelemetryTypes.LATEST_TELEMETRY"
<tb-attribute-table [defaultAttributeScope]="latestTelemetryTypes.LATEST_TELEMETRY" disableAttributeScopeSelection
disableAttributeScopeSelection [active]="telemetryTab.isActive"
[active]="telemetryTab.isActive" [entityId]="entity.id"
[entityId]="entity.id" [entityName]="entity.name">
[entityName]="entity.name"> </tb-attribute-table>
</tb-attribute-table> </mat-tab>
</mat-tab> @if (authUser.authority === authorities.TENANT_ADMIN) {
<mat-tab *ngIf="entity" <mat-tab #alarmRulesTab="matTab" label="{{'alarm-rule.alarm-rules-tab' | translate }}">
label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab"> <tb-alarm-rules-table [active]="alarmRulesTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [ownerId]="entity.tenantId"></tb-alarm-rules-table>
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table> </mat-tab>
</mat-tab> }
<mat-tab *ngIf="entity" <mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
label="{{ 'customer.events' | translate }}" #eventsTab="matTab"> <tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
<tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id" </mat-tab>
[entityId]="entity.id"></tb-event-table> <mat-tab label="{{ 'customer.events' | translate }}" #eventsTab="matTab">
</mat-tab> <tb-event-table [defaultEventType]="eventTypes.ERROR"
<mat-tab *ngIf="entity" [active]="eventsTab.isActive"
label="{{ 'relation.relations' | translate }}" #relationsTab="matTab"> [tenantId]="entity.tenantId.id"
<tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table> [entityId]="entity.id">
</mat-tab> </tb-event-table>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" </mat-tab>
label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab"> <mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab">
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.CUSTOMER" [customerId]="entity.id"></tb-audit-log-table> <tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" @if (authUser.authority === authorities.TENANT_ADMIN) {
label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab"> <mat-tab label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab">
<tb-version-control detailsMode="true" singleEntityMode="true" <tb-audit-log-table detailsMode="true"
(versionRestored)="entitiesTableConfig.updateData()" [active]="auditLogsTab.isActive"
[active]="versionControlTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [externalEntityId]="entity.externalId || entity.id"></tb-version-control> [auditLogMode]="auditLogModes.CUSTOMER"
</mat-tab> [customerId]="entity.id">
</tb-audit-log-table>
</mat-tab>
<mat-tab label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
<tb-version-control detailsMode="true"
singleEntityMode="true"
(versionRestored)="entitiesTableConfig.updateData()"
[active]="versionControlTab.isActive"
[entityId]="entity.id"
[entityName]="entity.name"
[externalEntityId]="entity.externalId || entity.id">
</tb-version-control>
</mat-tab>
}
}

136
ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html

@ -15,54 +15,88 @@
limitations under the License. limitations under the License.
--> -->
<mat-tab *ngIf="entity" #transportType="matTab" @if (entity) {
label="{{ 'device-profile.transport-configuration' | translate }}"> <mat-tab #transportType="matTab" label="{{ 'device-profile.transport-configuration' | translate }}">
<div class="mat-padding" [formGroup]="detailsForm" *ngIf="transportType.isActive"> <div class="mat-padding" [formGroup]="detailsForm" *ngIf="transportType.isActive">
<mat-form-field class="mat-block"> <mat-form-field class="mat-block">
<mat-label translate>device-profile.transport-type</mat-label> <mat-label translate>device-profile.transport-type</mat-label>
<mat-select formControlName="transportType" required> <mat-select formControlName="transportType" required>
<mat-option *ngFor="let type of deviceTransportTypes" [value]="type"> <mat-option *ngFor="let type of deviceTransportTypes" [value]="type">
{{deviceTransportTypeTranslations.get(type) | translate}} {{deviceTransportTypeTranslations.get(type) | translate}}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-hint *ngIf="detailsForm.get('transportType').value"> <mat-hint *ngIf="detailsForm.get('transportType').value">
{{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}}
</mat-hint> </mat-hint>
<mat-error *ngIf="detailsForm.get('transportType').hasError('required')"> <mat-error *ngIf="detailsForm.get('transportType').hasError('required')">
{{ 'device-profile.transport-type-required' | translate }} {{ 'device-profile.transport-type-required' | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<div formGroupName="profileData"> <div formGroupName="profileData">
<tb-device-profile-transport-configuration <tb-device-profile-transport-configuration
formControlName="transportConfiguration" formControlName="transportConfiguration"
[isAdd]="isTransportTypeChanged"> [isAdd]="isTransportTypeChanged">
</tb-device-profile-transport-configuration> </tb-device-profile-transport-configuration>
</div> </div>
</div>
</mat-tab>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN && !isEdit"
label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab">
<tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id" [entityName]="entity.name"/>
</mat-tab>
<mat-tab *ngIf="entity" #alarmRules="matTab"
label="{{ this.detailsForm.get('profileData.alarms').value?.length
? ('device-profile.alarm-rules-with-count' | translate: { count: this.detailsForm.get('profileData.alarms').value.length })
: 'device-profile.alarm-rules' | translate }}">
<div class="mat-padding" [formGroup]="detailsForm" *ngIf="alarmRules.isActive">
<div formGroupName="profileData">
<tb-device-profile-alarms formControlName="alarms" [deviceProfileId]="entity.id"></tb-device-profile-alarms>
</div> </div>
</div> </mat-tab>
</mat-tab> @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) {
<mat-tab *ngIf="entity" #deviceProvisioning="matTab" <mat-tab label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab">
label="{{ 'device-profile.device-provisioning' | translate }}"> <tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [ownerId]="entity.tenantId"/>
<div class="mat-padding" [formGroup]="detailsForm" *ngIf="deviceProvisioning.isActive"> </mat-tab>
<div formGroupName="profileData"> }
<tb-device-profile-provision-configuration formControlName="provisionConfiguration">
</tb-device-profile-provision-configuration> @if (hasOldRules || authUser.authority === authorities.TENANT_ADMIN && !isEdit) {
<mat-tab #alarmRules="matTab" label="{{'alarm-rule.alarm-rules-tab' | translate }}">
<section class="flex flex-col h-full">
@if (hasOldRules && !isEdit) {
<div class="flex flex-row items-center justify-end" style="padding: 16px 16px 0;">
<tb-toggle-select [(ngModel)]="alarmRulesOldVersion" appearance="fill">
<tb-toggle-option [value]="false">{{ 'alarm-rule.alarm-rules-new' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'alarm-rule.alarm-rules-old' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
}
@if (alarmRulesOldVersion) {
<div class="mat-padding" [formGroup]="detailsForm" *ngIf="alarmRules.isActive">
<div formGroupName="profileData">
<tb-device-profile-alarms formControlName="alarms" [deviceProfileId]="entity.id"></tb-device-profile-alarms>
</div>
</div>
} @else {
<div class="relative h-full">
<tb-alarm-rules-table [active]="alarmRules.isActive" [entityId]="entity.id" [entityName]="entity.name" [ownerId]="entity.tenantId"></tb-alarm-rules-table>
</div>
}
</section>
</mat-tab>
}
<mat-tab #deviceProvisioning="matTab" label="{{ 'device-profile.device-provisioning' | translate }}">
<div class="mat-padding" [formGroup]="detailsForm" *ngIf="deviceProvisioning.isActive">
<div formGroupName="profileData">
<tb-device-profile-provision-configuration formControlName="provisionConfiguration">
</tb-device-profile-provision-configuration>
</div>
</div> </div>
</div> </mat-tab>
</mat-tab> @if (!isEdit) {
<mat-tab #auditLogsTab="matTab" label="{{ 'audit-log.audit-logs' | translate }}">
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table>
</mat-tab>
}
@if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) {
<mat-tab label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
<tb-version-control detailsMode="true" singleEntityMode="true"
(versionRestored)="entitiesTableConfig.updateData()"
[active]="versionControlTab.isActive"
[entityId]="entity.id"
[entityName]="entity.name"
[externalEntityId]="entity.externalId || entity.id">
</tb-version-control>
</mat-tab>
}
}
<mat-tab *ngIf="false" <mat-tab *ngIf="false"
label="{{'device-profile.profile-configuration' | translate }}" #deviceProfile="matTab"> label="{{'device-profile.profile-configuration' | translate }}" #deviceProfile="matTab">
<div class="mat-padding" [formGroup]="detailsForm"> <div class="mat-padding" [formGroup]="detailsForm">
@ -73,13 +107,3 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity && !isEdit" #auditLogsTab="matTab"
label="{{ 'audit-log.audit-logs' | translate }}">
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table>
</mat-tab>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN && !isEdit"
label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
<tb-version-control detailsMode="true" singleEntityMode="true"
(versionRestored)="entitiesTableConfig.updateData()"
[active]="versionControlTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [externalEntityId]="entity.externalId || entity.id"></tb-version-control>
</mat-tab>

5
ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts

@ -41,6 +41,9 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent<DeviceProfil
isTransportTypeChanged = false; isTransportTypeChanged = false;
hasOldRules = false;
alarmRulesOldVersion = false;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
private destroyRef: DestroyRef) { private destroyRef: DestroyRef) {
super(store); super(store);
@ -57,6 +60,8 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent<DeviceProfil
protected setEntity(entity: DeviceProfile) { protected setEntity(entity: DeviceProfile) {
this.isTransportTypeChanged = false; this.isTransportTypeChanged = false;
this.hasOldRules = !!entity?.profileData?.alarms?.length;
this.alarmRulesOldVersion = this.isEdit || false;
super.setEntity(entity); super.setEntity(entity);
} }

89
ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html

@ -15,47 +15,48 @@
limitations under the License. limitations under the License.
--> -->
<mat-tab *ngIf="entity" @if (entity) {
label="{{ 'attribute.attributes' | translate }}" #attributesTab="matTab"> <mat-tab label="{{ 'attribute.attributes' | translate }}" #attributesTab="matTab">
<tb-attribute-table [defaultAttributeScope]="attributeScopes.CLIENT_SCOPE" <tb-attribute-table [defaultAttributeScope]="attributeScopes.CLIENT_SCOPE"
[active]="attributesTab.isActive" [active]="attributesTab.isActive"
[entityId]="entity.id" [entityId]="entity.id"
[entityName]="entity.name"> [entityName]="entity.name">
</tb-attribute-table> </tb-attribute-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity" <mat-tab label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab">
label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab"> <tb-attribute-table [defaultAttributeScope]="latestTelemetryTypes.LATEST_TELEMETRY"
<tb-attribute-table [defaultAttributeScope]="latestTelemetryTypes.LATEST_TELEMETRY" disableAttributeScopeSelection
disableAttributeScopeSelection [active]="telemetryTab.isActive"
[active]="telemetryTab.isActive" [entityId]="entity.id"
[entityId]="entity.id" [entityName]="entity.name">
[entityName]="entity.name"> </tb-attribute-table>
</tb-attribute-table> </mat-tab>
</mat-tab> @if (authUser.authority === authorities.TENANT_ADMIN) {
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" <mat-tab label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab">
label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab"> <tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id"/>
<tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id"/> </mat-tab>
</mat-tab> <mat-tab #calculatedFieldsAlarmRules="matTab" label="{{'alarm-rule.alarm-rules-tab' | translate }}">
<mat-tab *ngIf="entity" <tb-alarm-rules-table [active]="calculatedFieldsAlarmRules.isActive" [entityId]="entity.id" [entityName]="entity.name"></tb-alarm-rules-table>
label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab"> </mat-tab>
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table> }
</mat-tab> <mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
<mat-tab *ngIf="entity" <tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
label="{{ 'device.events' | translate }}" #eventsTab="matTab"> </mat-tab>
<tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id" <mat-tab label="{{ 'device.events' | translate }}" #eventsTab="matTab">
[entityId]="entity.id"></tb-event-table> <tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id"
</mat-tab> [entityId]="entity.id"></tb-event-table>
<mat-tab *ngIf="entity" </mat-tab>
label="{{ 'relation.relations' | translate }}" #relationsTab="matTab"> <mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab">
<tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table> <tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" <mat-tab label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab">
label="{{ 'audit-log.audit-logs' | translate }}" #auditLogsTab="matTab"> <tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table>
<tb-audit-log-table detailsMode="true" [active]="auditLogsTab.isActive" [auditLogMode]="auditLogModes.ENTITY" [entityId]="entity.id"></tb-audit-log-table> </mat-tab>
</mat-tab> @if (authUser.authority === authorities.TENANT_ADMIN) {
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN" <mat-tab label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab">
label="{{ 'version-control.version-control' | translate }}" #versionControlTab="matTab"> <tb-version-control detailsMode="true" singleEntityMode="true"
<tb-version-control detailsMode="true" singleEntityMode="true" (versionRestored)="entitiesTableConfig.updateData()"
(versionRestored)="entitiesTableConfig.updateData()" [active]="versionControlTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [externalEntityId]="entity.externalId || entity.id"></tb-version-control>
[active]="versionControlTab.isActive" [entityId]="entity.id" [entityName]="entity.name" [externalEntityId]="entity.externalId || entity.id"></tb-version-control> </mat-tab>
</mat-tab> }
}

152
ui-ngx/src/app/shared/models/alarm-rule.models.ts

@ -0,0 +1,152 @@
///
/// Copyright © 2016-2025 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 { CustomTimeSchedulerItem } from "@shared/models/device.models";
import { DashboardId } from "@shared/models/id/dashboard-id";
import { TimeUnit } from "@shared/models/time/time.models";
import {
BooleanOperation,
ComplexOperation,
EntityKeyValueType,
FilterPredicateType,
NumericOperation,
StringOperation
} from "@shared/models/query/query.models";
export enum AlarmRuleScheduleType {
ANY_TIME = 'ANY_TIME',
SPECIFIC_TIME = 'SPECIFIC_TIME',
CUSTOM = 'CUSTOM'
}
export const AlarmRuleScheduleTypeTranslationMap = new Map<AlarmRuleScheduleType, string>(
[
[AlarmRuleScheduleType.ANY_TIME, 'alarm-rule.schedule.any-time'],
[AlarmRuleScheduleType.SPECIFIC_TIME, 'alarm-rule.schedule.specific-time'],
[AlarmRuleScheduleType.CUSTOM, 'alarm-rule.schedule.custom']
]
);
export enum AlarmRuleConditionType {
SIMPLE = 'SIMPLE',
DURATION = 'DURATION',
REPEATING = 'REPEATING'
}
export const AlarmRuleConditionTypeTranslationMap = new Map<AlarmRuleConditionType, string>(
[
[AlarmRuleConditionType.SIMPLE, 'alarm-rule.conditions.simple'],
[AlarmRuleConditionType.DURATION, 'alarm-rule.conditions.duration'],
[AlarmRuleConditionType.REPEATING, 'alarm-rule.conditions.repeating']
]
);
export enum AlarmRuleExpressionType {
SIMPLE = 'SIMPLE',
TBEL = 'TBEL',
}
export const FilterPredicateTypeTranslationMap = new Map<FilterPredicateType, string>(
[
[FilterPredicateType.STRING, 'alarm-rule.filter-predicate-type.string'],
[FilterPredicateType.NUMERIC, 'alarm-rule.filter-predicate-type.numeric'],
[FilterPredicateType.BOOLEAN, 'alarm-rule.filter-predicate-type.boolean'],
[FilterPredicateType.COMPLEX, 'alarm-rule.filter-predicate-type.complex']
]
);
export interface AlarmRule {
condition: AlarmRuleCondition;
alarmDetails?: string;
dashboardId?: DashboardId;
}
export interface AlarmRuleCondition {
type: AlarmRuleConditionType;
expression: AlarmRuleExpression;
schedule?: AlarmRuleSchedule;
unit?: TimeUnit;
value?: AlarmRuleValue<number>;
count?: AlarmRuleValue<number>;
}
export interface AlarmRuleExpression {
type: AlarmRuleExpressionType;
expression?: string;
filters?: Array<AlarmRuleFilter>;
operation?: ComplexOperation;
}
export interface AlarmRuleSchedule {
staticValue?: {
type?: AlarmRuleScheduleType;
timezone?: string;
daysOfWeek?: number[];
startsOn?: number;
endsOn?: number;
items?: CustomTimeSchedulerItem[];
};
dynamicValueArgument?: string;
}
export interface AlarmRuleFilter {
argument: string;
valueType: EntityKeyValueType;
operation: ComplexOperation;
predicates: AlarmRuleFilterPredicate[];
}
export interface AlarmRulePredicateInfo {
keyFilterPredicate: AlarmRuleFilterPredicate;
}
export type AlarmRuleFilterPredicate = StringAlarmRuleFilterPredicate |
NumericAlarmRuleFilterPredicate |
BooleanAlarmRuleFilterPredicate |
ComplexAlarmRuleFilterPredicate;
export interface AlarmRuleValue<T> {
dynamicValueArgument?: string;
staticValue?: T
}
export interface StringAlarmRuleFilterPredicate {
type: FilterPredicateType.STRING;
operation: StringOperation;
value: AlarmRuleValue<string>;
ignoreCase: boolean;
}
export interface NumericAlarmRuleFilterPredicate {
type: FilterPredicateType.NUMERIC;
operation: NumericOperation;
value: AlarmRuleValue<number>;
}
export interface BooleanAlarmRuleFilterPredicate {
type: FilterPredicateType.BOOLEAN;
operation: BooleanOperation;
value: AlarmRuleValue<boolean>;
}
export interface BaseComplexFilterPredicate<T extends AlarmRuleFilterPredicate> {
type: FilterPredicateType.COMPLEX;
operation: ComplexOperation;
predicates: Array<T>;
}
export type ComplexAlarmRuleFilterPredicate = BaseComplexFilterPredicate<AlarmRuleFilterPredicate>;

86
ui-ngx/src/app/shared/models/calculated-field.models.ts

@ -31,6 +31,8 @@ import {
} from '@shared/models/ace/ace.models'; } from '@shared/models/ace/ace.models';
import { EntitySearchDirection } from '@shared/models/relation.models'; import { EntitySearchDirection } from '@shared/models/relation.models';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { AlarmRule } from "@shared/models/alarm-rule.models";
import { AlarmSeverity } from "@shared/models/alarm.models";
interface BaseCalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> { interface BaseCalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
entityId: EntityId; entityId: EntityId;
@ -56,27 +58,50 @@ export interface CalculatedFieldPropagation extends BaseCalculatedField {
configuration: CalculatedFieldPropagationConfiguration; configuration: CalculatedFieldPropagationConfiguration;
} }
export interface CalculatedFieldAlarmRule extends BaseCalculatedField {
type: CalculatedFieldType.ALARM;
configuration: CalculatedFieldAlarmRuleConfiguration;
}
export type CalculatedField = export type CalculatedField =
| CalculatedFieldSimple | CalculatedFieldSimple
| CalculatedFieldScript | CalculatedFieldScript
| CalculatedFieldGeofencing | CalculatedFieldGeofencing
| CalculatedFieldPropagation; | CalculatedFieldPropagation
| CalculatedFieldAlarmRule;
export enum CalculatedFieldType { export enum CalculatedFieldType {
SIMPLE = 'SIMPLE', SIMPLE = 'SIMPLE',
SCRIPT = 'SCRIPT', SCRIPT = 'SCRIPT',
GEOFENCING = 'GEOFENCING', GEOFENCING = 'GEOFENCING',
PROPAGATION = 'PROPAGATION', PROPAGATION = 'PROPAGATION',
RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION' RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION',
ALARM = 'ALARM',
}
interface CalculatedFieldTypeTranslate {
name: string;
hint?: string;
} }
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>( export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, CalculatedFieldTypeTranslate>(
[ [
[CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'], [CalculatedFieldType.SIMPLE, {
[CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'], name: 'calculated-fields.type.simple'
[CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'], }],
[CalculatedFieldType.PROPAGATION, 'calculated-fields.type.propagation'], [CalculatedFieldType.SCRIPT, {
[CalculatedFieldType.RELATED_ENTITIES_AGGREGATION, 'calculated-fields.type.related-entities-aggregation'], name: 'calculated-fields.type.script'
}],
[CalculatedFieldType.GEOFENCING, {
name: 'calculated-fields.type.geofencing'
}],
[CalculatedFieldType.PROPAGATION, {
name: 'calculated-fields.type.propagation'
}],
[CalculatedFieldType.RELATED_ENTITIES_AGGREGATION, {
name: 'calculated-fields.type.related-entities-aggregation',
hint: 'calculated-fields.type.related-entities-aggregation-hint'
}],
] ]
) )
@ -85,7 +110,8 @@ export type CalculatedFieldConfiguration =
| CalculatedFieldScriptConfiguration | CalculatedFieldScriptConfiguration
| CalculatedFieldGeofencingConfiguration | CalculatedFieldGeofencingConfiguration
| CalculatedFieldPropagationConfiguration | CalculatedFieldPropagationConfiguration
| CalculatedFieldRelatedAggregationConfiguration; | CalculatedFieldRelatedAggregationConfiguration
| CalculatedFieldAlarmRuleConfiguration;
export interface CalculatedFieldSimpleConfiguration { export interface CalculatedFieldSimpleConfiguration {
type: CalculatedFieldType.SIMPLE; type: CalculatedFieldType.SIMPLE;
@ -117,7 +143,7 @@ export interface CalculatedFieldRelatedAggregationConfiguration {
metrics: Record<string, CalculatedFieldAggMetric>; metrics: Record<string, CalculatedFieldAggMetric>;
deduplicationIntervalInSec: number; deduplicationIntervalInSec: number;
useLatestTs: boolean; useLatestTs: boolean;
output: Omit<CalculatedFieldSimpleOutput, 'name'>; output: CalculatedFieldOutput & { decimalsByDefault?: number; };
} }
interface BasePropagationConfiguration { interface BasePropagationConfiguration {
@ -127,6 +153,17 @@ interface BasePropagationConfiguration {
output: CalculatedFieldOutput; output: CalculatedFieldOutput;
} }
interface CalculatedFieldAlarmRuleConfiguration {
type: CalculatedFieldType.ALARM;
arguments: Record<string, CalculatedFieldArgument>;
createRules: Record<AlarmSeverity, AlarmRule>;
clearRule?: AlarmRule;
propagate: boolean;
propagateToOwner: boolean;
propagateToTenant: boolean;
propagateRelationTypes?: Array<string>;
}
export interface PropagationWithNoExpression extends BasePropagationConfiguration { export interface PropagationWithNoExpression extends BasePropagationConfiguration {
applyExpressionToResolvedArguments: false; applyExpressionToResolvedArguments: false;
} }
@ -140,12 +177,20 @@ export type CalculatedFieldPropagationConfiguration =
| PropagationWithNoExpression | PropagationWithNoExpression
| PropagationWithExpression; | PropagationWithExpression;
export interface CalculatedFieldOutput { export type CalculatedFieldOutput =
type: OutputType; | CalculatedFieldOutputAttribute
scope?: AttributeScope; | CalculatedFieldOutputTimeSeries;
export interface CalculatedFieldOutputAttribute {
type: OutputType.Attribute,
scope: AttributeScope;
}
export interface CalculatedFieldOutputTimeSeries {
type: OutputType.Timeseries;
} }
export interface CalculatedFieldSimpleOutput extends CalculatedFieldOutput { export type CalculatedFieldSimpleOutput = CalculatedFieldOutput & {
name: string; name: string;
decimalsByDefault?: number; decimalsByDefault?: number;
} }
@ -156,6 +201,7 @@ export enum ArgumentEntityType {
Asset = 'ASSET', Asset = 'ASSET',
Customer = 'CUSTOMER', Customer = 'CUSTOMER',
Tenant = 'TENANT', Tenant = 'TENANT',
Owner = 'CURRENT_OWNER',
RelationQuery = 'RELATION_PATH_QUERY', RelationQuery = 'RELATION_PATH_QUERY',
} }
@ -166,6 +212,7 @@ export const ArgumentEntityTypeTranslations = new Map<ArgumentEntityType, string
[ArgumentEntityType.Asset, 'calculated-fields.argument-asset'], [ArgumentEntityType.Asset, 'calculated-fields.argument-asset'],
[ArgumentEntityType.Customer, 'calculated-fields.argument-customer'], [ArgumentEntityType.Customer, 'calculated-fields.argument-customer'],
[ArgumentEntityType.Tenant, 'calculated-fields.argument-tenant'], [ArgumentEntityType.Tenant, 'calculated-fields.argument-tenant'],
[ArgumentEntityType.Owner, 'calculated-fields.argument-owner'],
[ArgumentEntityType.RelationQuery, 'calculated-fields.argument-relation-query'], [ArgumentEntityType.RelationQuery, 'calculated-fields.argument-relation-query'],
] ]
) )
@ -248,10 +295,15 @@ export interface CalculatedFieldArgument {
refEntityKey: RefEntityKey; refEntityKey: RefEntityKey;
defaultValue?: string; defaultValue?: string;
refEntityId?: RefEntityId; refEntityId?: RefEntityId;
refDynamicSourceConfiguration?: RefDynamicSourceConfiguration;
limit?: number; limit?: number;
timeWindow?: number; timeWindow?: number;
} }
export interface RefDynamicSourceConfiguration {
type: ArgumentEntityType.Owner;
}
export enum AggFunction { export enum AggFunction {
AVG='AVG', AVG='AVG',
MIN='MIN', MIN='MIN',
@ -304,14 +356,14 @@ export interface CalculatedFieldGeofencing {
perimeterKeyName: string; perimeterKeyName: string;
reportStrategy: GeofencingReportStrategy; reportStrategy: GeofencingReportStrategy;
refEntityId?: RefEntityId; refEntityId?: RefEntityId;
refDynamicSourceConfiguration: RefDynamicSourceConfiguration; refDynamicSourceConfiguration: RefDynamicSourceGeofencingConfiguration;
createRelationsWithMatchedZones: boolean; createRelationsWithMatchedZones: boolean;
relationType: string; relationType: string;
direction: EntitySearchDirection; direction: EntitySearchDirection;
} }
export interface RefDynamicSourceConfiguration { export interface RefDynamicSourceGeofencingConfiguration {
type?: ArgumentEntityType.RelationQuery; type: ArgumentEntityType.RelationQuery | ArgumentEntityType.Owner;
levels?: Array<RelationPathLevel>; levels?: Array<RelationPathLevel>;
} }

123
ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md

@ -0,0 +1,123 @@
#### Active all time schedule format
An attribute with a dynamic value for an active all-time schedule format must contain an empty JSON object or JSON in the following format:
```javascript
{
"type": "ANY_TIME"
}
```
#### Specific time schedule format
An attribute with a dynamic value for a specific schedule format must have JSON in the following format:
```javascript
{
"type": "SPECIFIC_TIME",
"daysOfWeek": [
2,
4
],
"endsOn": 0,
"startsOn": 0,
"timezone": "Europe/Kiev"
}
```
<ul>
<li>
<b>timezone:</b> this value is used to designate the timezone you are using.
</li>
<li>
<b>daysOfWeek:</b> this value is used to designate the days in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active.
</li>
<li>
<b>startsOn:</b> this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated days.
</li>
<li>
<b>endsOn:</b> this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified days.
</li>
</ul>
When <b>startsOn</b> and <b>endsOn</b> equals 0 it's means that the schedule will be active the whole day.
#### Custom time schedule format
An attribute with a dynamic value for a custom schedule format must have JSON in the following format:
```javascript
{
"type": "CUSTOM"
"timezone": "Europe/Kiev",
"items": [
{
"dayOfWeek": 1,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 2,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 3,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 4,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 5,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 6,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 7,
"enabled": true,
"endsOn": 0,
"startsOn": 0
}
]
}
```
<ul>
<li>
<b>timezone:</b> this value is used to designate the timezone you are using.
</li>
<li>
<b>items:</b> the array of values representing the days on which the schedule will be active.
</li>
</ul>
One array item contains such fields:
<ul>
<li>
<b>dayOfWeek:</b> this value is used to designate the specified day in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active.
</li>
<li>
<b>enabled:</b> this <code>boolean</code> value, used to designate that the specified day in the schedule will be enabled.
</li>
<li>
<b>startsOn:</b> this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated day.
</li>
<li>
<b>endsOn:</b> this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified day.
</li>
</ul>
When <b>startsOn</b> and <b>endsOn</b> equals 0 it's means that the schedule will be active the whole day.

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

@ -1057,7 +1057,8 @@
"script": "Script", "script": "Script",
"geofencing" : "Geofencing", "geofencing" : "Geofencing",
"propagation": "Propagation", "propagation": "Propagation",
"related-entities-aggregation": "Related entities aggregation" "related-entities-aggregation": "Related entities aggregation",
"related-entities-aggregation-hint": "Aggregation of data from related entities"
}, },
"arguments": "Arguments", "arguments": "Arguments",
"decimals-by-default": "Decimals by default", "decimals-by-default": "Decimals by default",
@ -1076,6 +1077,7 @@
"argument-asset": "Asset", "argument-asset": "Asset",
"argument-customer": "Customer", "argument-customer": "Customer",
"argument-tenant": "Current tenant", "argument-tenant": "Current tenant",
"argument-owner": "Current owner",
"argument-relation-query": "Related entities", "argument-relation-query": "Related entities",
"argument-type": "Argument type", "argument-type": "Argument type",
"see-debug-events": "See debug events", "see-debug-events": "See debug events",
@ -1244,6 +1246,138 @@
"metrics": "Defines metrics aggregated based on the configured arguments." "metrics": "Defines metrics aggregated based on the configured arguments."
} }
}, },
"alarm-rule": {
"alarm-rules-tab": "Alarm rules",
"alarm-rule": "Alarm rule",
"alarm-rules": "Alarm rules",
"alarm-rules-old": "Old",
"alarm-rules-new": "New",
"severities": "Severities",
"cleared": "Cleared",
"delete-title": "Are you sure you want to delete the alarm rule '{{title}}'?",
"delete-text": "Be careful, after the confirmation the alarm rule and all related data will become unrecoverable.",
"delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 alarm rule} other {# alarm rules} }?",
"delete-multiple-text": "Be careful, after the confirmation all selected alarm rules will be removed and all related data will become unrecoverable.",
"create": "Create new alarm rule",
"no-found": "No alarm rules found",
"list": "{ count, plural, =1 {One alarm rule} other {List of # alarm rules} }",
"selected-fields": "{ count, plural, =1 {1 alarm rule} other {# alarm rules} } selected",
"import": "Import alarm rule",
"export": "Export alarm rule",
"export-failed-error": "Unable to export alarm rule: {{error}}",
"alarm-type": "Alarm type",
"alarm-type-required": "Alarm type is required.",
"alarm-type-pattern": "Alarm type is invalid.",
"alarm-type-max-length": "Alarm type should be less than 256 characters.",
"clear-alarm": "Clear alarm",
"value-argument": "Argument",
"value-argument-required": "Argument is required.",
"static-settings": "Static settings",
"configuration": "Configuration",
"static-schedule": "Static",
"dynamic-schedule": "Dynamic",
"operation-and": "AND",
"operation-or": "OR",
"condition-during": "During {{during}}",
"condition-during-dynamic": "During \"{{ attribute }}\"",
"condition-repeat-times": "Repeats { count, plural, =1 {1 time} other {# times} }",
"condition-repeat-times-dynamic": "Repeats \"{ attribute }\"",
"filter-preview": "Filter preview",
"condition-settings": "Condition settings",
"static": "Static",
"dynamic": "Dynamic",
"argument-filters": "Argument filters",
"argument-name": "Argument name",
"value-type": "Value type",
"general": "General",
"filter": "Filter",
"operation": "Operation",
"value-source": "Value source",
"value": "Value",
"ignore-case": "Ignore case",
"condition": "Condition",
"script": "Script",
"add-filter": "Add filter",
"edit-filter": "Edit filter",
"conditions": {
"simple": "Simple",
"duration": "Duration",
"repeating": "Repeating"
},
"schedule-title": "Schedule",
"edit-schedule": "Edit alarm schedule",
"schedule-type": "Scheduler type",
"schedule-type-required": "Scheduler type is required.",
"schedule": {
"any-time": "Active all the time",
"specific-time": "Active at a specific time",
"custom": "Custom"
},
"schedule-day": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
},
"schedule-days": "Days",
"schedule-time": "Time",
"schedule-time-from": "From",
"schedule-time-to": "To",
"schedule-days-of-week-required": "At least one day of week should be selected.",
"expression-type": {
"simple": "Simple",
"tbel": "TBEL"
},
"operation-type": {
"and": "And",
"or": "Or"
},
"filter-predicate-type": {
"string": "String",
"numeric": "Numeric",
"boolean": "Boolean",
"complex": "Complex"
},
"alarm-rule-additional-info": "Additional info",
"edit-alarm-rule-additional-info": "Edit additional info",
"alarm-rule-additional-info-placeholder": "Please provide your comments and adjustments here to display them within Alarm details under Additional info",
"alarm-rule-additional-info-hint": "Hint: use <code><span style=\"color: #000;\">$&#123;</span>Argument name<span style=\"color: #000;\">&#125;</span></code> to substitute values of the arguments that are used in alarm rule condition.",
"alarm-rule-mobile-dashboard": "Mobile dashboard",
"alarm-rule-mobile-dashboard-hint": "Used by mobile application as an alarm details dashboard",
"alarm-rule-no-mobile-dashboard": "No dashboard selected",
"alarm-rule-condition": "Alarm rule condition",
"enter-alarm-rule-condition-prompt": "Please add alarm rule condition",
"edit-alarm-rule-condition": "Edit alarm rule condition",
"condition-type": "Condition type",
"select-alarm-severity": "Select alarm severity",
"add-create-alarm-rule-prompt": "Please add create alarm rule",
"add-create-alarm-rule": "Add create condition",
"add-clear-alarm-rule": "Add clear condition",
"condition-duration": "Condition duration",
"condition-duration-value": "Duration value",
"condition-duration-time-unit": "Time unit",
"condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.",
"condition-duration-value-pattern": "Duration value should be integers.",
"condition-duration-value-required": "Duration value is required.",
"condition-duration-time-unit-required": "Time unit is required.",
"condition-repeating-value": "Count of events",
"condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.",
"condition-repeating-value-pattern": "Count of events should be integers.",
"condition-repeating-value-required": "Count of events is required.",
"create-alarm-rules": "Create alarm rules",
"clear-alarm-rule": "Clear alarm rule",
"no-clear-alarm-rule": "No clear condition configured",
"advanced-settings": "Advanced settings",
"propagate-alarm": "Propagate alarm to related entities",
"alarm-rule-relation-types-list": "Relation types",
"alarm-rule-relation-types-list-hint": "Defines relation types to filter the related entities. If not set, the alarm will be propagated to all related entities.",
"propagate-alarm-to-owner": "Propagate alarm to entity owner (Customer or Tenant)",
"propagate-alarm-to-tenant": "Propagate alarm to Tenant",
"debugging": "Alarm rule debugging"
},
"ai-models": { "ai-models": {
"ai-models": "AI models", "ai-models": "AI models",
"ai-model": "AI model", "ai-model": "AI model",

Loading…
Cancel
Save