Browse Source

Merge pull request #14107 from ShvaykaD/feature/propagation-cf

Propagation Calculated Field
pull/14223/head
Viacheslav Klimov 11 months ago
committed by GitHub
parent
commit
5d4fe3365a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 13
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  3. 36
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  4. 41
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  5. 49
      application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java
  6. 8
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
  8. 16
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  9. 59
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  11. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
  12. 11
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  13. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
  14. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java
  15. 72
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java
  16. 113
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java
  17. 2
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  18. 12
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  19. 166
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  20. 97
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  21. 143
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java
  22. 247
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java
  23. 47
      application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java
  24. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  25. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java
  26. 7
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java
  27. 10
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
  28. 6
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  29. 96
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java
  30. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java
  31. 13
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java
  32. 16
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java
  33. 10
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java
  34. 4
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  35. 153
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java
  36. 2
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java
  37. 31
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java
  38. 29
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java
  39. 18
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
  40. 3
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java
  41. 4
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java
  42. 42
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java
  43. 3
      dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java
  44. 16
      dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java
  45. 2
      dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java
  46. 18
      dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java
  47. 117
      dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java
  48. 44
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
  49. 183
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java
  50. 61
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  51. 74
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  52. 134
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  53. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss
  54. 113
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  55. 15
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html
  56. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss
  57. 60
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts
  58. 45
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts
  59. 116
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts
  60. 203
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  61. 237
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  62. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html
  63. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss
  64. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts
  65. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html
  66. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.scss
  67. 8
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts
  68. 68
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html
  69. 157
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts
  70. 52
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts
  71. 86
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  72. 148
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  73. 36
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts
  74. 99
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html
  75. 174
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts
  76. 44
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts
  77. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts
  78. 98
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html
  79. 206
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts
  80. 44
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts
  81. 48
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  82. 216
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html
  83. 216
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts
  84. 2
      ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts
  85. 2
      ui-ngx/src/app/modules/home/pages/asset/asset.module.ts
  86. 2
      ui-ngx/src/app/shared/components/time-unit-input.component.ts
  87. 96
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  88. 11
      ui-ngx/src/app/shared/models/tenant.model.ts
  89. 30
      ui-ngx/src/assets/locale/locale.constant-en_US.json

8
application/src/main/data/upgrade/basic/schema_update.sql

@ -34,6 +34,12 @@ SET profile_data = jsonb_set(
WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
THEN NULL
ELSE to_jsonb(10)
END,
'maxRelatedEntitiesToReturnPerCfArgument',
CASE
WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
THEN NULL
ELSE to_jsonb(100)
END
)
),
@ -43,6 +49,8 @@ WHERE NOT (
(profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF'
AND
(profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
AND
(profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
);
-- UPDATE TENANT PROFILE CONFIGURATION END

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

@ -334,13 +334,15 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state == null) {
state = createState(ctx);
justRestored = true;
} else if (ctx.shouldFetchDynamicArgumentsFromDb(state)) {
} else if (ctx.shouldFetchRelationQueryDynamicArgumentsFromDb(state)) {
log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId());
try {
Map<String, ArgumentEntry> dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId);
dynamicArgsFromDb.forEach(newArgValues::putIfAbsent);
var geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING) {
var geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.updateLastDynamicArgumentsRefreshTs();
}
} catch (Exception e) {
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
}
@ -368,9 +370,10 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
state.init();
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.hasRelationQueryDynamicArguments()) {
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
geofencingState.updateLastDynamicArgumentsRefreshTs();
}
Map<String, ArgumentEntry> arguments = fetchArguments(ctx);

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

@ -53,6 +53,8 @@ import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry;
@ -87,23 +89,28 @@ public abstract class AbstractCalculatedFieldProcessingService {
protected abstract String getExecutorNamePrefix();
protected ListenableFuture<Map<String, ArgumentEntry>> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCalculatedField().getType()) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCfType()) {
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts);
case SIMPLE, SCRIPT, ALARM -> {
Map<String, ListenableFuture<ArgumentEntry>> futures = new HashMap<>();
for (var entry : ctx.getArguments().entrySet()) {
var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue());
var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts);
futures.put(entry.getKey(), argValueFuture);
}
yield futures;
}
case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts);
};
if (ctx.getCfType() == PROPAGATION) {
argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId));
}
return Futures.whenAllComplete(argFutures.values())
.call(() -> resolveArgumentFutures(argFutures),
MoreExecutors.directExecutor());
}
private Map<String, ListenableFuture<ArgumentEntry>> getBaseCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
Map<String, ListenableFuture<ArgumentEntry>> futures = new HashMap<>();
for (var entry : ctx.getArguments().entrySet()) {
var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue());
var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts);
futures.put(entry.getKey(), argValueFuture);
}
return futures;
}
protected EntityId resolveEntityId(TenantId tenantId, EntityId entityId, Argument argument) {
if (argument.getRefEntityId() != null) {
return argument.getRefEntityId();
@ -131,6 +138,11 @@ public abstract class AbstractCalculatedFieldProcessingService {
));
}
protected ListenableFuture<ArgumentEntry> fetchPropagationCalculatedFieldArgument(CalculatedFieldCtx ctx, EntityId entityId) {
ListenableFuture<List<EntityId>> propagationEntityIds = fromDynamicSource(ctx.getTenantId(), entityId, ctx.getPropagationArgument());
return Futures.transform(propagationEntityIds, ArgumentEntry::createPropagationArgument, MoreExecutors.directExecutor());
}
protected Map<String, ListenableFuture<ArgumentEntry>> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly, long startTs) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>();
Set<Map.Entry<String, Argument>> entries = ctx.getArguments().entrySet();
@ -161,6 +173,10 @@ public abstract class AbstractCalculatedFieldProcessingService {
if (!value.hasDynamicSource()) {
return Futures.immediateFuture(List.of(entityId));
}
return fromDynamicSource(tenantId, entityId, value);
}
private ListenableFuture<List<EntityId>> fromDynamicSource(TenantId tenantId, EntityId entityId, Argument value) {
var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration();
return switch (refDynamicSourceConfiguration.getType()) {
case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId)));

41
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java

@ -23,7 +23,6 @@ import org.thingsboard.server.actors.calculatedField.MultipleTbCallback;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -50,11 +49,13 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent
@ -89,11 +90,11 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
@Override
public Map<String, ArgumentEntry> fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
// only scheduledSupported CF instances supports dynamic arguments scheduled updates
if (!ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) {
return Map.of();
}
return resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis()));
return switch (ctx.getCfType()) {
case GEOFENCING -> resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis()));
case PROPAGATION -> resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId)));
default -> Collections.emptyMap();
};
}
@Override
@ -112,13 +113,35 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
@Override
public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
try {
if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) {
TbMsg msg = result.toTbMsg(entityId, cfIds);
sendMsgToRuleEngine(tenantId, entityId, callback, msg);
return;
}
List<EntityId> propagationEntityIds = propagationCalculatedFieldResult.getPropagationEntityIds();
if (propagationEntityIds.isEmpty()) {
callback.onSuccess();
}
if (propagationEntityIds.size() == 1) {
EntityId propagationEntityId = propagationEntityIds.get(0);
TbMsg msg = result.toTbMsg(propagationEntityId, cfIds);
sendMsgToRuleEngine(tenantId, propagationEntityId, callback, msg);
return;
}
MultipleTbCallback multipleTbCallback = new MultipleTbCallback(propagationEntityIds.size(), callback);
for (var propagationEntityId : propagationEntityIds) {
TbMsg msg = result.toTbMsg(propagationEntityId, cfIds);
sendMsgToRuleEngine(tenantId, propagationEntityId, multipleTbCallback, msg);
}
}
private void sendMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbCallback callback, TbMsg msg) {
try {
clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
callback.onSuccess();
log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg);
callback.onSuccess();
}
@Override
@ -127,7 +150,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
}
});
} catch (Exception e) {
log.warn("[{}][{}] Failed to push message to rule engine. CalculatedFieldResult: {}", tenantId, entityId, result, e);
log.warn("[{}][{}] Failed to push message to rule engine: {}", tenantId, entityId, msg, e);
callback.onFailure(e);
}
}

49
application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java

@ -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.
*/
package org.thingsboard.server.service.cf;
import lombok.Builder;
import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.common.msg.TbMsg;
import java.util.List;
@Data
@Builder
public final class PropagationCalculatedFieldResult implements CalculatedFieldResult {
private final List<EntityId> propagationEntityIds;
private final TelemetryCalculatedFieldResult result;
@Override
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) {
return result.toTbMsg(entityId, cfIds);
}
@Override
public String stringValue() {
return result.stringValue();
}
@Override
public boolean isEmpty() {
return CollectionsUtil.isEmpty(propagationEntityIds) || result.isEmpty();
}
}

8
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import java.util.List;
import java.util.Map;
@ -35,7 +36,8 @@ import java.util.Map;
@JsonSubTypes({
@JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"),
@JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"),
@JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING")
@JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING"),
@JsonSubTypes.Type(value = PropagationArgumentEntry.class, name = "PROPAGATION")
})
public interface ArgumentEntry {
@ -66,4 +68,8 @@ public interface ArgumentEntry {
return new GeofencingArgumentEntry(entityIdkvEntryMap);
}
static ArgumentEntry createPropagationArgument(List<EntityId> entityIds) {
return new PropagationArgumentEntry(entityIds);
}
}

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java

@ -16,5 +16,5 @@
package org.thingsboard.server.service.cf.ctx.state;
public enum ArgumentEntryType {
SINGLE_VALUE, TS_ROLLING, GEOFENCING
SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION
}

16
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java

@ -15,8 +15,10 @@
*/
package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.Getter;
import lombok.Setter;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
@ -123,6 +125,20 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
protected void validateNewEntry(String key, ArgumentEntry newEntry) {}
protected ObjectNode toSimpleResult(boolean useLatestTs, ObjectNode valuesNode) {
if (!useLatestTs) {
return valuesNode;
}
long latestTs = getLatestTimestamp();
if (latestTs == -1) {
return valuesNode;
}
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTs);
resultNode.set("values", valuesNode);
return resultNode;
}
private void updateLastUpdateTimestamp(ArgumentEntry entry) {
long newTs = this.latestTimestamp;
if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {

59
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java

@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ExpressionBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
@ -107,6 +108,9 @@ public class CalculatedFieldCtx {
private long scheduledUpdateIntervalMillis;
private Argument propagationArgument;
private boolean applyExpressionForResolvedArguments;
public CalculatedFieldCtx(CalculatedField calculatedField,
ActorSystemContext systemContext) {
this.calculatedField = calculatedField;
@ -160,6 +164,11 @@ public class CalculatedFieldCtx {
}
});
}
if (calculatedField.getConfiguration() instanceof PropagationCalculatedFieldConfiguration propagationConfig) {
propagationArgument = propagationConfig.toPropagationArgument();
applyExpressionForResolvedArguments = propagationConfig.isApplyExpressionToResolvedArguments();
relationQueryDynamicArguments = true;
}
}
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) {
this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L;
@ -199,6 +208,12 @@ public class CalculatedFieldCtx {
});
initialized = true;
}
case PROPAGATION -> {
if (applyExpressionForResolvedArguments) {
initTbelExpression(expression);
}
initialized = true;
}
}
}
@ -486,8 +501,8 @@ public class CalculatedFieldCtx {
return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId);
}
public boolean hasContextOnlyChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression)) {
public boolean hasContextOnlyChanges(CalculatedFieldCtx other) { // has changes that do not require state reinit and will be picked up by the state on the fly
if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !Objects.equals(expression, other.expression)) {
return true;
}
if (!Objects.equals(output, other.output)) {
@ -496,10 +511,7 @@ public class CalculatedFieldCtx {
if (cfType == CalculatedFieldType.ALARM && !calculatedField.getName().equals(other.getCalculatedField().getName())) {
return true;
}
if (scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis) {
return true;
}
return false;
return scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis;
}
public boolean hasStateChanges(CalculatedFieldCtx other) {
@ -514,10 +526,7 @@ public class CalculatedFieldCtx {
return true;
}
}
if (hasGeofencingZoneGroupConfigurationChanges(other)) {
return true;
}
return false;
return hasGeofencingZoneGroupConfigurationChanges(other);
}
private boolean hasGeofencingZoneGroupConfigurationChanges(CalculatedFieldCtx other) {
@ -528,21 +537,29 @@ public class CalculatedFieldCtx {
return false;
}
public boolean hasRelationQueryDynamicArguments() {
return relationQueryDynamicArguments && scheduledUpdateIntervalMillis != -1;
private boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalMillis != -1;
}
public boolean shouldFetchDynamicArgumentsFromDb(CalculatedFieldState state) {
if (!hasRelationQueryDynamicArguments()) {
public boolean shouldFetchRelationQueryDynamicArgumentsFromDb(CalculatedFieldState state) {
if (!relationQueryDynamicArguments) {
return false;
}
if (!(state instanceof GeofencingCalculatedFieldState geofencingState)) {
return false;
}
if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) {
return true;
}
return geofencingState.getLastDynamicArgumentsRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis;
return switch (cfType) {
case PROPAGATION -> true;
case GEOFENCING -> {
if (!isScheduledUpdateEnabled()) {
yield false;
}
var geofencingState = (GeofencingCalculatedFieldState) state;
if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) {
yield true;
}
yield geofencingState.getLastDynamicArgumentsRefreshTs() <
System.currentTimeMillis() - scheduledUpdateIntervalMillis;
}
default -> false;
};
}
public void stop() {

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java

@ -29,6 +29,7 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.io.Closeable;
import java.util.Map;
@ -40,7 +41,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg
@Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"),
@Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"),
@Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM")
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION")
})
public interface CalculatedFieldState extends Closeable {

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java

@ -34,7 +34,7 @@ import java.util.Map;
@EqualsAndHashCode(callSuper = true)
public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
private CalculatedFieldScriptEngine tbelExpression;
protected CalculatedFieldScriptEngine tbelExpression;
public ScriptCalculatedFieldState(EntityId entityId) {
super(entityId);

11
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java

@ -81,16 +81,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
} else {
valuesNode.set(outputName, JacksonUtil.valueToTree(result));
}
long latestTs = getLatestTimestamp();
if (useLatestTs && latestTs != -1) {
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTs);
resultNode.set("values", valuesNode);
return resultNode;
} else {
return valuesNode;
}
return toSimpleResult(useLatestTs, valuesNode);
}
@Override

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java

@ -18,7 +18,7 @@ package org.thingsboard.server.service.cf.ctx.state.geofencing;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg;
import org.thingsboard.script.api.tbel.TbelCfGeofencingArg;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.util.ProtoUtils;
@ -83,7 +83,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry {
@Override
public TbelCfArg toTbelCfArg() {
return new TbelCfTsGeofencingArg(zoneStates);
return new TbelCfGeofencingArg(zoneStates);
}
private Map<EntityId, GeofencingZoneState> toZones(Map<EntityId, KvEntry> entityIdKvEntryMap) {

12
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java

@ -146,6 +146,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
lastDynamicArgumentsRefreshTs = -1;
}
public void updateLastDynamicArgumentsRefreshTs() {
lastDynamicArgumentsRefreshTs = System.currentTimeMillis();
}
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() {
return arguments.entrySet()
.stream()
@ -168,13 +172,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
}
private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) {
if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) {
return valuesNode;
}
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTimestamp);
resultNode.set("values", valuesNode);
return resultNode;
return toSimpleResult(outputType == OutputType.TIME_SERIES, valuesNode);
}
private GeofencingEvalResult aggregateZoneGroup(List<GeofencingEvalResult> zoneResults) {

72
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java

@ -0,0 +1,72 @@
/**
* 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.
*/
package org.thingsboard.server.service.cf.ctx.state.propagation;
import lombok.Data;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfPropagationArg;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
import java.util.List;
@Data
public class PropagationArgumentEntry implements ArgumentEntry {
private List<EntityId> propagationEntityIds;
private boolean forceResetPrevious;
public PropagationArgumentEntry(List<EntityId> propagationEntityIds) {
this.propagationEntityIds = propagationEntityIds;
}
@Override
public ArgumentEntryType getType() {
return ArgumentEntryType.PROPAGATION;
}
@Override
public Object getValue() {
return propagationEntityIds;
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
if (!(entry instanceof PropagationArgumentEntry propagationArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType());
}
if (propagationArgumentEntry.isEmpty()) {
propagationEntityIds.clear();
} else {
propagationEntityIds = propagationArgumentEntry.getPropagationEntityIds();
}
return true;
}
@Override
public boolean isEmpty() {
return CollectionsUtil.isEmpty(propagationEntityIds);
}
@Override
public TbelCfArg toTbelCfArg() {
return new TbelCfPropagationArg(propagationEntityIds);
}
}

113
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java

@ -0,0 +1,113 @@
/**
* 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.
*/
package org.thingsboard.server.service.cf.ctx.state.propagation;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.Map;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState {
public PropagationCalculatedFieldState(EntityId entityId) {
super(entityId);
}
@Override
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) {
this.ctx = ctx;
this.actorCtx = actorCtx;
this.requiredArguments = ctx.getArgNames();
if (ctx.isApplyExpressionForResolvedArguments()) {
this.tbelExpression = ctx.getTbelExpressions().get(ctx.getExpression());
}
}
@Override
public boolean isReady() {
if (!super.isReady()) {
return false;
}
ArgumentEntry propagationArg = arguments.get(PROPAGATION_CONFIG_ARGUMENT);
return propagationArg != null && !propagationArg.isEmpty();
}
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.PROPAGATION;
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) {
ArgumentEntry argumentEntry = arguments.get(PROPAGATION_CONFIG_ARGUMENT);
if (!(argumentEntry instanceof PropagationArgumentEntry propagationArgumentEntry) || propagationArgumentEntry.isEmpty()) {
return Futures.immediateFuture(PropagationCalculatedFieldResult.builder().build());
}
if (ctx.isApplyExpressionForResolvedArguments()) {
return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult ->
PropagationCalculatedFieldResult.builder()
.propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds())
.result((TelemetryCalculatedFieldResult) telemetryCfResult)
.build(),
MoreExecutors.directExecutor());
}
return Futures.immediateFuture(PropagationCalculatedFieldResult.builder()
.propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds())
.result(toTelemetryResult(ctx))
.build());
}
private TelemetryCalculatedFieldResult toTelemetryResult(CalculatedFieldCtx ctx) {
Output output = ctx.getOutput();
TelemetryCalculatedFieldResult.TelemetryCalculatedFieldResultBuilder telemetryCfBuilder =
TelemetryCalculatedFieldResult.builder()
.type(output.getType())
.scope(output.getScope());
ObjectNode valuesNode = JacksonUtil.newObjectNode();
arguments.forEach((outputKey, argumentEntry) -> {
if (argumentEntry instanceof PropagationArgumentEntry) {
return;
}
if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) {
JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), outputKey);
return;
}
throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + outputKey + ". " +
"Only Latest telemetry or Attribute arguments supported for 'Arguments Only' propagation mode!");
});
ObjectNode result = toSimpleResult(output.getType() == OutputType.TIME_SERIES, valuesNode);
telemetryCfBuilder.result(result);
return telemetryCfBuilder.build();
}
}

2
application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java

@ -36,6 +36,7 @@ import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.Optional;
@ -79,6 +80,7 @@ public class CalculatedFieldArgumentUtils {
case SCRIPT -> new ScriptCalculatedFieldState(entityId);
case GEOFENCING -> new GeofencingCalculatedFieldState(entityId);
case ALARM -> new AlarmCalculatedFieldState(entityId);
case PROPAGATION -> new PropagationCalculatedFieldState(entityId);
};
}

12
application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java

@ -50,6 +50,7 @@ import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.Map;
import java.util.Optional;
@ -92,12 +93,10 @@ public class CalculatedFieldUtils {
.setType(state.getType().name());
state.getArguments().forEach((argName, argEntry) -> {
if (argEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry));
} else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) {
builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry));
} else if (argEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry) {
builder.addGeofencingArguments(toGeofencingArgumentProto(argName, geofencingArgumentEntry));
switch (argEntry.getType()) {
case SINGLE_VALUE -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) argEntry));
case TS_ROLLING -> builder.addRollingValueArguments(toRollingArgumentProto(argName, (TsRollingArgumentEntry) argEntry));
case GEOFENCING -> builder.addGeofencingArguments(toGeofencingArgumentProto(argName, (GeofencingArgumentEntry) argEntry));
}
});
if (state instanceof AlarmCalculatedFieldState alarmState) {
@ -187,6 +186,7 @@ public class CalculatedFieldUtils {
case SCRIPT -> new ScriptCalculatedFieldState(id.entityId());
case GEOFENCING -> new GeofencingCalculatedFieldState(id.entityId());
case ALARM -> new AlarmCalculatedFieldState(id.entityId());
case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId());
};
proto.getSingleValueArgumentsList().forEach(argProto ->

166
application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java

@ -17,6 +17,7 @@ package org.thingsboard.server.cf;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.thingsboard.common.util.JacksonUtil;
@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
@ -34,6 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
@ -997,6 +1000,169 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
@Test
public void testPropagationCalculatedField_withExpression() throws Exception {
// --- Arrange entities ---
Device device = createDevice("Propagation Device With Expression", "sn-prop-1");
Asset asset1 = createAsset("Propagated Asset 1", null);
Asset asset2 = createAsset("Propagated Asset 2", null);
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel1).andExpect(status().isOk());
doPost("/api/relation", rel2).andExpect(status().isOk());
// Telemetry on device
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"temperature\":12.5}")).andExpect(status().isOk());
// --- Build CF: PROPAGATION with expression ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.PROPAGATION);
cf.setName("Propagation CF (expr)");
cf.setConfigurationVersion(1);
PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setApplyExpressionToResolvedArguments(true);
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
cfg.setArguments(Map.of("t", arg));
cfg.setExpression("{\"testResult\": t * 2}");
Output output = new Output();
output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
cfg.setOutput(output);
cf.setConfiguration(cfg);
doPost("/api/calculatedField", cf, CalculatedField.class);
// --- Assert propagated calculation (expression applied) ---
await().alias("propagation expr mode evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult");
ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult");
assertThat(attrs1).isNotNull();
assertThat(attrs2).isNotNull();
assertThat(attrs1.get(0).get("value").asDouble()).isEqualTo(25.0);
assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(25.0);
});
String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
asset1.getId().getId(), EntityType.ASSET,
EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE
);
doDelete(deleteUrl).andExpect(status().isOk());
doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk());
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"temperature\":25}")).andExpect(status().isOk());
// --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) ---
await().alias("propagation expr mode evaluation after temperature update")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult");
ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult");
assertThat(attrs1).isNullOrEmpty();
assertThat(attrs2).isNotNull();
assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(50);
});
}
@Test
public void testPropagationCalculatedField_withoutExpression() throws Exception {
// --- Arrange entities ---
Device device = createDevice("Propagation Device Without Expression", "sn-prop-2");
Asset asset1 = createAsset("Propagated Asset 1", null);
Asset asset2 = createAsset("Propagated Asset 2", null);
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel1).andExpect(status().isOk());
doPost("/api/relation", rel2).andExpect(status().isOk());
// Telemetry on device
long ts = System.currentTimeMillis() - 300000L;
postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts));
// --- Build CF: PROPAGATION without expression ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.PROPAGATION);
cf.setName("Propagation CF (args-only)");
cf.setConfigurationVersion(1);
PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
cfg.setArguments(Map.of("temperatureComputed", arg));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
cfg.setOutput(output);
cf.setConfiguration(cfg);
doPost("/api/calculatedField", cf, CalculatedField.class);
// --- Assert propagated calculation (arguments-only mode) ---
await().alias("propagation args-only evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed");
ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed");
assertThat(telemetry1).isNotNull();
assertThat(telemetry2).isNotNull();
assertThat(telemetry1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
assertThat(telemetry1.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5);
assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5);
});
String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
asset1.getId().getId(), EntityType.ASSET,
EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE
);
doDelete(deleteUrl).andExpect(status().isOk());
doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperatureComputed&deleteAllDataForKeys=true").andExpect(status().isOk());
// Update telemetry on device
long newTs = System.currentTimeMillis() - 300000L;
postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs));
// --- Assert propagated calculation (arguments-only mode after update) ---
await().alias("propagation args-only evaluation after temperature update")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed");
ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed");
assertThat(telemetry1).isNotNull();
assertThat(telemetry2).isNotNull();
assertThat(telemetry1.get("temperatureComputed").get(0).get("value")).isEqualTo(NullNode.instance);
assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs));
assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25);
});
}
private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
}

97
application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java

@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
@ -36,6 +37,7 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.Geofencing
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.security.Authority;
@ -45,6 +47,7 @@ import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@ -82,7 +85,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testSaveCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@ -110,7 +113,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testSaveGeofencingCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), getGeofencingCalculatedFieldConfig());
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.GEOFENCING);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@ -135,10 +138,48 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
@Test
public void testSavePropagationCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
assertThat(savedCalculatedField).isNotNull();
assertThat(savedCalculatedField.getId()).isNotNull();
assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getPropagationCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
.andExpect(status().isOk());
}
@Test
public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION, getPropagationCalculatedFieldConfig(null));
doPost("/api/calculatedField", calculatedField)
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("arguments must not be empty")));
}
@Test
public void testGetCalculatedFieldById() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class);
@ -153,7 +194,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testGetCalculatedFields() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
assertThat(getCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData())
@ -165,7 +206,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testDeleteCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@ -176,17 +217,27 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound());
}
private CalculatedField getCalculatedField(EntityId entityId) {
return getCalculatedField(entityId, getSimpleCalculatedFieldConfig());
private CalculatedField getSimpleCalculatedField(EntityId entityId) {
return getCalculatedField(entityId, CalculatedFieldType.SIMPLE);
}
private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType) {
return getCalculatedField(entityId, cfType, null);
}
private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldConfiguration configuration) {
private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType, CalculatedFieldConfiguration customConfiguration) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(entityId);
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setType(cfType);
calculatedField.setName("Test Calculated Field");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(configuration);
if (customConfiguration != null) {
calculatedField.setConfiguration(customConfiguration);
} else switch (cfType) {
case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig());
case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig());
case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig());
}
calculatedField.setVersion(1L);
return calculatedField;
}
@ -211,6 +262,32 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
return config;
}
private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig() {
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
return getPropagationCalculatedFieldConfig(Map.of("t", arg));
}
private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig(Map<String, Argument> arguments) {
var config = new PropagationCalculatedFieldConfiguration();
config.setRelationType(EntityRelation.CONTAINS_TYPE);
config.setDirection(EntitySearchDirection.TO);
config.setApplyExpressionToResolvedArguments(false);
config.setExpression(null);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
config.setArguments(arguments);
return config;
}
private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();

143
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java

@ -0,0 +1,143 @@
/**
* 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.
*/
package org.thingsboard.server.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfPropagationArg;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class PropagationArgumentEntryTest {
private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805"));
private final AssetId ENTITY_2_ID = new AssetId(UUID.fromString("7bd85073-ded5-414f-a2ef-bd56ad3dbf6a"));
private final AssetId ENTITY_3_ID = new AssetId(UUID.fromString("d64f3e51-2ec2-472f-b475-b095ef8bdc70"));
private PropagationArgumentEntry entry;
@BeforeEach
void setUp() {
List<EntityId> propagationEntityIds = new ArrayList<>();
propagationEntityIds.add(ENTITY_1_ID);
propagationEntityIds.add(ENTITY_2_ID);
entry = new PropagationArgumentEntry(propagationEntityIds);
}
@Test
void testArgumentEntryType() {
assertThat(entry.getType()).isEqualTo(ArgumentEntryType.PROPAGATION);
}
@Test
void testIsEmpty() {
PropagationArgumentEntry emptyEntry = new PropagationArgumentEntry(List.of());
assertThat(emptyEntry.isEmpty()).isTrue();
}
@Test
void testIsEmptyWhenNullList() {
PropagationArgumentEntry nullListEntry = new PropagationArgumentEntry(null);
assertThat(nullListEntry.isEmpty()).isTrue();
}
@Test
void testGetValueReturnsPropagationIds() {
assertThat(entry.getValue()).isInstanceOf(List.class);
@SuppressWarnings("unchecked")
List<AssetId> value = (List<AssetId>) entry.getValue();
assertThat(value).containsExactly(ENTITY_1_ID, ENTITY_2_ID);
}
@Test
void testUpdateEntryWhenSingleEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE");
}
@Test
void testUpdateEntryWhenRollingEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING");
}
@Test
void testUpdateEntryReplacesWithNewIds() {
var newIds = new ArrayList<EntityId>(List.of(ENTITY_3_ID, ENTITY_1_ID));
var updated = new PropagationArgumentEntry(newIds);
boolean changed = entry.updateEntry(updated);
assertThat(changed).isTrue();
assertThat(entry.getPropagationEntityIds()).containsExactlyElementsOf(newIds);
}
@Test
void testUpdateEntryClearsWhenNewEntryIsEmpty() {
var updatedEmpty = new PropagationArgumentEntry(List.of());
boolean changed = entry.updateEntry(updatedEmpty);
assertThat(changed).isTrue();
assertThat(entry.getPropagationEntityIds()).isEmpty();
}
@Test
void testUpdateEntryClearsWhenNewEntryIsNullList() {
var updatedNull = new PropagationArgumentEntry(null);
boolean changed = entry.updateEntry(updatedNull);
assertThat(changed).isTrue();
assertThat(entry.getPropagationEntityIds()).isEmpty();
}
@Test
@SuppressWarnings("unchecked")
void testToTbelCfArgWithValues() {
TbelCfArg arg = entry.toTbelCfArg();
assertThat(arg).isInstanceOf(TbelCfPropagationArg.class);
TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) arg;
assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class);
assertThat((List<EntityId>) tbelCfPropagationArg.getValue()).containsExactly(ENTITY_1_ID, ENTITY_2_ID);
}
@Test
@SuppressWarnings("unchecked")
void testToTbelCfArgWithEmptyValues() {
var empty = new PropagationArgumentEntry(List.of());
TbelCfArg emptyArg = empty.toTbelCfArg();
assertThat(emptyArg).isInstanceOf(TbelCfPropagationArg.class);
TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) emptyArg;
assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class);
assertThat((List<EntityId>) tbelCfPropagationArg.getValue()).isEmpty();
}
}

247
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java

@ -0,0 +1,247 @@
/**
* 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.
*/
package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.DefaultTbelInvokeService;
import org.thingsboard.script.api.tbel.TbelInvokeService;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
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.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.stats.DefaultStatsFactory;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class})
public class PropagationCalculatedFieldStateTest {
private static final String TEMPERATURE_ARGUMENT_NAME = "t";
private static final String TEST_RESULT_EXPRESSION_KEY = "testResult";
private static final double TEMPERATURE_VALUE = 12.5;
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("6c3513cb-85e7-4510-8746-1ba01859a8ce"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("be960a50-c029-4698-b2ec-c56a543c561c"));
private final AssetId ASSET_ID_1 = new AssetId(UUID.fromString("d26f0e5b-7d7d-4a61-9f5e-08ab97b30734"));
private final AssetId ASSET_ID_2 = new AssetId(UUID.fromString("1933a317-4df5-4d36-9800-68aded74579b"));
private final SingleValueArgumentEntry singleValueArgEntry =
new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", TEMPERATURE_VALUE), 99L);
private final PropagationArgumentEntry propagationArgEntry =
new PropagationArgumentEntry(new ArrayList<>(List.of(ASSET_ID_2, ASSET_ID_1)));
private PropagationCalculatedFieldState state;
private CalculatedFieldCtx ctx;
@Autowired
private TbelInvokeService tbelInvokeService;
@MockitoBean
private ApiLimitService apiLimitService;
@MockitoBean
private ActorSystemContext actorSystemContext;
@BeforeEach
void setUp() {
when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService);
when(actorSystemContext.getApiLimitService()).thenReturn(apiLimitService);
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
}
void initCtxAndState(boolean applyExpressionToResolvedArguments) {
ctx = new CalculatedFieldCtx(getCalculatedField(applyExpressionToResolvedArguments), actorSystemContext);
ctx.init();
state = new PropagationCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
}
@Test
void testType() {
initCtxAndState(false);
assertThat(state.getType()).isEqualTo(CalculatedFieldType.PROPAGATION);
}
@Test
void testInitAddsRequiredArgument() {
initCtxAndState(false);
assertThat(state.getRequiredArguments()).containsExactlyInAnyOrder(TEMPERATURE_ARGUMENT_NAME);
}
@Test
void testIsReadyReturnFalseWhenNoArgumentsSet() {
initCtxAndState(false);
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenPropagationArgIsNull() {
initCtxAndState(false);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenPropagationArgIsEmpty() {
initCtxAndState(false);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList()));
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenPropagationArgHasEntities() {
initCtxAndState(false);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry);
assertThat(state.isReady()).isTrue();
}
@Test
void testPerformCalculationWithEmptyPropagationArg() throws Exception {
initCtxAndState(false);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList()));
PropagationCalculatedFieldResult result = performCalculation();
assertThat(result).isNotNull();
assertThat(result.isEmpty()).isTrue();
assertThat(result.getPropagationEntityIds()).isNullOrEmpty();
}
@Test
void testPerformCalculationWithArgumentsOnlyMode() throws Exception {
initCtxAndState(false);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
PropagationCalculatedFieldResult propagationResult = performCalculation();
assertThat(propagationResult).isNotNull();
assertThat(propagationResult.isEmpty()).isFalse();
assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1);
TelemetryCalculatedFieldResult result = propagationResult.getResult();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES);
assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE);
ObjectNode expectedNode = JacksonUtil.newObjectNode();
JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue(), TEMPERATURE_ARGUMENT_NAME);
assertThat(result.getResult()).isEqualTo(expectedNode);
}
@Test
void testPerformCalculationWithExpressionResultMode() throws Exception {
initCtxAndState(true);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
PropagationCalculatedFieldResult propagationResult = performCalculation();
assertThat(propagationResult).isNotNull();
assertThat(propagationResult.isEmpty()).isFalse();
assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1);
TelemetryCalculatedFieldResult result = propagationResult.getResult();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES);
assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE);
ObjectNode expectedNode = JacksonUtil.newObjectNode();
expectedNode.put(TEST_RESULT_EXPRESSION_KEY, TEMPERATURE_VALUE * 2);
assertThat(result.getResult()).isEqualTo(expectedNode);
}
private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setTenantId(TENANT_ID);
calculatedField.setEntityId(DEVICE_ID);
calculatedField.setType(CalculatedFieldType.PROPAGATION);
calculatedField.setName("Test Propagation CF");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(getCalculatedFieldConfig(applyExpressionToResolvedArguments));
calculatedField.setVersion(1L);
return calculatedField;
}
private CalculatedFieldConfiguration getCalculatedFieldConfig(boolean applyExpressionToResolvedArguments) {
var config = new PropagationCalculatedFieldConfiguration();
config.setDirection(EntitySearchDirection.TO);
config.setRelationType(EntityRelation.CONTAINS_TYPE);
config.setApplyExpressionToResolvedArguments(applyExpressionToResolvedArguments);
Argument temperatureArg = new Argument();
ReferencedEntityKey tempKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null);
temperatureArg.setRefEntityKey(tempKey);
config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg));
config.setExpression("{" + TEST_RESULT_EXPRESSION_KEY + ": " + TEMPERATURE_ARGUMENT_NAME + " * 2}");
Output output = new Output();
output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
config.setOutput(output);
return config;
}
private PropagationCalculatedFieldResult performCalculation() throws ExecutionException, InterruptedException {
return (PropagationCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get();
}
}

47
application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java

@ -26,22 +26,28 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@ExtendWith(MockitoExtension.class)
@ -87,11 +93,9 @@ class CalculatedFieldUtilsTest {
CalculatedFieldState state = new GeofencingCalculatedFieldState(DEVICE_ID);
state.update(Map.of("geofencingArgumentTest", geofencingArgumentEntry), mock(CalculatedFieldCtx.class));
// when
CalculatedFieldStateProto proto = toProto(stateId, state);
// then
CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(stateId, proto);
assertThat(fromProto)
.usingRecursiveComparison()
.ignoringFields("requiredArguments")
@ -105,4 +109,41 @@ class CalculatedFieldUtilsTest {
assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull();
}
@Test
void toProtoAndFromProto_shouldCreatePropagationStateWithoutPropagationArgument() {
// given
CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class);
given(stateId.tenantId()).willReturn(TENANT_ID);
given(stateId.cfId()).willReturn(CF_ID);
given(stateId.entityId()).willReturn(DEVICE_ID);
AssetId propagationAssetId = new AssetId(UUID.fromString("17bbf99c-3b87-4d21-b07d-da7409bb2bb7"));
PropagationArgumentEntry propagationArgumentEntry = new PropagationArgumentEntry(List.of(propagationAssetId));
long lastUpdateTs = System.currentTimeMillis();
SingleValueArgumentEntry singleValueArgumentEntry = new SingleValueArgumentEntry(new BaseAttributeKvEntry(new StringDataEntry("state", "active"), lastUpdateTs, 1L));
CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class);
CalculatedFieldState state = new PropagationCalculatedFieldState(DEVICE_ID);
state.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry, "state", singleValueArgumentEntry), cfCtxMock);
// when
CalculatedFieldStateProto proto = toProto(stateId, state);
// then
CalculatedFieldState restored = CalculatedFieldUtils.fromProto(stateId, proto);
// Propagation argument is not persisted -> should be absent after restore
assertThat(restored).isNotNull();
assertThat(restored).isInstanceOf(PropagationCalculatedFieldState.class);
PropagationCalculatedFieldState propagationState = (PropagationCalculatedFieldState) restored;
assertThat(propagationState.getEntityId()).isEqualTo(DEVICE_ID);
assertThat(propagationState.getArguments()).isNotNull();
assertThat(propagationState.getArguments().get(PROPAGATION_CONFIG_ARGUMENT)).isNull();
assertThat(propagationState.getArguments().get("state")).isNotNull().isEqualTo(singleValueArgumentEntry);
}
}

3
common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java

@ -24,7 +24,8 @@ public enum CalculatedFieldType {
SIMPLE,
SCRIPT,
GEOFENCING,
ALARM;
ALARM,
PROPAGATION;
public static final Set<CalculatedFieldType> all = Collections.unmodifiableSet(EnumSet.allOf(CalculatedFieldType.class));

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java

@ -28,8 +28,6 @@ import java.util.Map;
@Data
public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
@Valid
@NotEmpty
private Map<String, Argument> arguments;
@Valid

7
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java

@ -15,6 +15,8 @@
*/
package org.thingsboard.server.common.data.cf.configuration;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.List;
@ -24,9 +26,14 @@ import java.util.stream.Collectors;
public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
@Valid
@NotEmpty
Map<String, Argument> getArguments();
default List<EntityId> getReferencedEntities() {
if (getArguments() == null) {
return List.of();
}
return getArguments().values().stream()
.map(Argument::getRefEntityId)
.filter(Objects::nonNull)

10
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java

@ -28,12 +28,16 @@ public abstract class BaseCalculatedFieldConfiguration implements ExpressionBase
@Override
public void validate() {
baseCalculatedFieldRestriction();
if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query configuration!");
}
}
protected void baseCalculatedFieldRestriction() {
if (arguments.containsKey("ctx")) {
throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used.");
}
if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query source configuration!");
}
}
}

6
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java

@ -27,7 +27,6 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@ -40,7 +39,8 @@ import java.util.stream.Collectors;
@Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"),
@Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"),
@Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM")
@Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CalculatedFieldConfiguration {
@ -54,7 +54,7 @@ public interface CalculatedFieldConfiguration {
@JsonIgnore
default List<EntityId> getReferencedEntities() {
return Collections.emptyList();
return List.of();
}
default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) {

96
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java

@ -0,0 +1,96 @@
/**
* 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.
*/
package org.thingsboard.server.common.data.cf.configuration;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class PropagationCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration {
public static final String PROPAGATION_CONFIG_ARGUMENT = "propagationCtx";
@NotNull
private EntitySearchDirection direction;
@NotBlank
private String relationType;
private boolean applyExpressionToResolvedArguments;
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.PROPAGATION;
}
@Override
public void validate() {
baseCalculatedFieldRestriction();
propagationRestriction();
if (!applyExpressionToResolvedArguments) {
arguments.forEach((name, argument) -> {
if (!currentEntitySource(argument)) {
throw new IllegalArgumentException("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!");
}
if (argument.getRefEntityKey() == null) {
throw new IllegalArgumentException("Argument: '" + name + "' doesn't have reference entity key configured!");
}
if (argument.getRefEntityKey().getType() == ArgumentType.TS_ROLLING) {
throw new IllegalArgumentException("Argument type: 'Time series rolling' detected for argument: '" + name + "'. " +
"Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!");
}
});
} else {
boolean noneMatchCurrentEntitySource = arguments.entrySet()
.stream()
.noneMatch(entry -> currentEntitySource(entry.getValue()));
if (noneMatchCurrentEntitySource) {
throw new IllegalArgumentException("At least one argument must be configured with the 'Current entity' " +
"source entity type for 'Expression result' propagation mode!");
}
if (StringUtils.isBlank(expression)) {
throw new IllegalArgumentException("Expression must be specified for 'Expression result' propagation mode!");
}
}
}
public Argument toPropagationArgument() {
var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(direction, relationType)));
var propagationArgument = new Argument();
propagationArgument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
return propagationArgument;
}
private void propagationRestriction() {
if (arguments.entrySet().stream().anyMatch(entry -> entry.getKey().equals(PROPAGATION_CONFIG_ARGUMENT))) {
throw new IllegalArgumentException("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used.");
}
}
private boolean currentEntitySource(Argument argument) {
return argument.getRefEntityId() == null && argument.getRefDynamicSourceConfiguration() == null;
}
}

3
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java

@ -15,10 +15,13 @@
*/
package org.thingsboard.server.common.data.cf.configuration;
import jakarta.validation.constraints.PositiveOrZero;
public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
boolean isScheduledUpdateEnabled();
@PositiveOrZero
int getScheduledUpdateInterval();
void setScheduledUpdateInterval(int interval);

13
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java

@ -16,8 +16,8 @@
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.thingsboard.server.common.data.StringUtils;
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.ReferencedEntityKey;
@ -30,18 +30,11 @@ public class EntityCoordinates {
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
@NotBlank
private final String latitudeKeyName;
@NotBlank
private final String longitudeKeyName;
public void validate() {
if (StringUtils.isBlank(latitudeKeyName)) {
throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!");
}
if (StringUtils.isBlank(longitudeKeyName)) {
throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!");
}
}
public Map<String, Argument> toArguments() {
return Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName),

16
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java

@ -16,6 +16,8 @@
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
@ -32,7 +34,12 @@ import java.util.Objects;
@Data
public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration {
@Valid
@NotNull
private EntityCoordinates entityCoordinates;
@Valid
@NotNull
private Map<String, ZoneGroupConfiguration> zoneGroups;
private boolean scheduledUpdateEnabled;
@ -56,7 +63,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal
@Override
public List<EntityId> getReferencedEntities() {
return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList();
return zoneGroups == null ? List.of() : zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList();
}
@Override
@ -66,13 +73,6 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal
@Override
public void validate() {
if (entityCoordinates == null) {
throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!");
}
entityCoordinates.validate();
if (zoneGroups == null || zoneGroups.isEmpty()) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
zoneGroups.forEach((key, value) -> value.validate(key));
}

10
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java

@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.lang.Nullable;
import org.thingsboard.server.common.data.AttributeScope;
@ -36,8 +38,10 @@ public class ZoneGroupConfiguration {
private EntityId refEntityId;
private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
@NotBlank
private final String perimeterKeyName;
@NotNull
private final GeofencingReportStrategy reportStrategy;
private final boolean createRelationsWithMatchedZones;
@ -48,12 +52,6 @@ public class ZoneGroupConfiguration {
if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) {
throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!");
}
if (StringUtils.isBlank(perimeterKeyName)) {
throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!");
}
if (reportStrategy == null) {
throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!");
}
if (refDynamicSourceConfiguration != null) {
refDynamicSourceConfiguration.validate();
}

4
common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java

@ -172,10 +172,12 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxCalculatedFieldsPerEntity = 5;
@Schema(example = "10")
private long maxArgumentsPerCF = 10;
@Schema(example = "3600")
@Schema(example = "60")
private int minAllowedScheduledUpdateIntervalInSecForCF = 60;
@Schema(example = "10")
private int maxRelationLevelPerCfArgument = 10;
@Schema(example = "100")
private int maxRelatedEntitiesToReturnPerCfArgument = 100;
@Builder.Default
@Min(value = 1, message = "must be at least 1")
@Schema(example = "1000")

153
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java

@ -0,0 +1,153 @@
/**
* 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.
*/
package org.thingsboard.server.common.data.cf.configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
@ExtendWith(MockitoExtension.class)
public class PropagationCalculatedFieldConfigurationTest {
@Test
void typeShouldBePropagation() {
var cfg = new PropagationCalculatedFieldConfiguration();
assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.PROPAGATION);
}
@Test
void validateShouldThrowWhenConfigurationDisallowArgumentsWithReferencedEntity() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argumentWithRefEntityIdSet = new Argument();
argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("bda14084-f40e-4acc-9b85-9d1dd209bb64")));
cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!");
}
@Test
void validateShouldThrowWhenConfigurationDisallowArgumentsWithDynamicReferenceConfiguration() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argumentWithDynamicRefEntitySource = new Argument();
argumentWithDynamicRefEntitySource.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
cfg.setArguments(Map.of("argumentWithDynamicRefEntitySource", argumentWithDynamicRefEntitySource));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!");
}
@Test
void validateShouldThrowWhenConfigurationHasNoArgumentsWithCurrentEntitySource() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argumentWithRefEntityIdSet = new Argument();
argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("3703e895-3f9b-4b75-a715-b68f1ad51944")));
cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet));
cfg.setApplyExpressionToResolvedArguments(true);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("At least one argument must be configured with the 'Current entity' " +
"source entity type for 'Expression result' propagation mode!");
}
@Test
void validateShouldThrowWhenUsedReservedPropagationArgumentName() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setArguments(Map.of(PROPAGATION_CONFIG_ARGUMENT, new Argument()));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used.");
}
@Test
void validateShouldThrowWhenUsedReservedCtxArgumentName() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("ctx", new Argument()));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument name 'ctx' is reserved and cannot be used.");
}
@Test
void validateShouldThrowWhenReferencedEntityKeyIsNotSet() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argument = new Argument();
cfg.setArguments(Map.of("someArgumentName", argument));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument: 'someArgumentName' doesn't have reference entity key configured!");
}
@Test
void validateShouldThrowWhenReferencedEntityKeyTypeIsTsRolling() {
var cfg = new PropagationCalculatedFieldConfiguration();
ReferencedEntityKey referencedEntityKey = new ReferencedEntityKey("someKey", ArgumentType.TS_ROLLING, null);
Argument argument = new Argument();
argument.setRefEntityKey(referencedEntityKey);
cfg.setArguments(Map.of("someArgumentName", argument));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument type: 'Time series rolling' detected for argument: 'someArgumentName'. " +
"Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!");
}
@Test
void validateShouldThrowWhenExpressionIsNotSet() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("someArgumentName", new Argument()));
cfg.setApplyExpressionToResolvedArguments(true);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Expression must be specified for 'Expression result' propagation mode!");
}
@Test
void validateToPropagationArgumentMethodCallReturnCorrectArgument() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
Argument propagationArgument = cfg.toPropagationArgument();
assertThat(propagationArgument).isNotNull();
assertThat(propagationArgument.getRefEntityId()).isNull();
assertThat(propagationArgument.getRefEntityKey()).isNull();
assertThat(propagationArgument.getDefaultValue()).isNull();
assertThat(propagationArgument.getTimeWindow()).isNull();
assertThat(propagationArgument.getLimit()).isNull();
assertThat(propagationArgument.getRefDynamicSourceConfiguration())
.isNotNull()
.isInstanceOf(RelationPathQueryDynamicSourceConfiguration.class);
var refDynamicSourceConfiguration = (RelationPathQueryDynamicSourceConfiguration) propagationArgument.getRefDynamicSourceConfiguration();
assertThat(refDynamicSourceConfiguration.getLevels()).isNotEmpty().hasSize(1);
var relationPathLevel = refDynamicSourceConfiguration.getLevels().get(0);
assertThat(relationPathLevel.direction()).isEqualTo(EntitySearchDirection.TO);
assertThat(relationPathLevel.relationType()).isEqualTo(EntityRelation.CONTAINS_TYPE);
}
}

2
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java

@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest {
@Test
void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() {
void validateDoesNotThrowAnyExceptionWhenScheduledUpdateIntervalIsGreaterThanMinAllowedIntervalInTenantProfile() {
int scheduledUpdateInterval = 60;
int minAllowedInterval = scheduledUpdateInterval - 1;

31
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java

@ -16,47 +16,16 @@
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
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.ReferencedEntityKey;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
public class EntityCoordinatesTest {
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) {
var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude");
assertThatThrownBy(entityCoordinates::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity coordinates latitude key name must be specified!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) {
var entityCoordinates = new EntityCoordinates("latitude", longitudeKey);
assertThatThrownBy(entityCoordinates::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity coordinates longitude key name must be specified!");
}
@Test
void validateShouldPassOnMinimalValidConfig() {
var entityCoordinates = new EntityCoordinates("latitude", "longitude");
assertThatCode(entityCoordinates::validate).doesNotThrowAnyException();
}
@Test
void validateToArgumentsMethodCallWithoutRefEntityId() {
var entityCoordinates = new EntityCoordinates("xPos", "yPos");

29
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java

@ -28,7 +28,6 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
@ -44,28 +43,7 @@ public class GeofencingCalculatedFieldConfigurationTest {
}
@Test
void validateShouldThrowWhenEntityCoordinatesNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field entity coordinates must be specified!");
}
@Test
void validateShouldThrowWhenZoneGroupsNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
cfg.setZoneGroups(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
@Test
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() {
void validateShouldCallValidateOnZoneGroups() {
var cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
@ -73,13 +51,11 @@ public class GeofencingCalculatedFieldConfigurationTest {
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration));
cfg.validate();
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfiguration).validate("someGroupName");
}
@Test
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() {
void validateShouldCallValidateOnZoneGroupsWithoutAnyExceptions() {
var cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
@ -93,7 +69,6 @@ public class GeofencingCalculatedFieldConfigurationTest {
assertThatCode(cfg::validate).doesNotThrowAnyException();
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfigurationA).validate(zoneGroupAName);
verify(zoneGroupConfigurationB).validate(zoneGroupBName);
}

18
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java

@ -45,24 +45,6 @@ public class ZoneGroupConfigurationTest {
.hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) {
var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldThrowWhenReportStrategyIsNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource

3
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java

@ -27,7 +27,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonSubTypes({
@JsonSubTypes.Type(value = TbelCfSingleValueArg.class, name = "SINGLE_VALUE"),
@JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING"),
@JsonSubTypes.Type(value = TbelCfTsGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"),
@JsonSubTypes.Type(value = TbelCfGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"),
@JsonSubTypes.Type(value = TbelCfPropagationArg.class, name = "PROPAGATION_CF_ARGUMENT_VALUE"),
})
public interface TbelCfArg extends TbelCfObject {

4
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java → common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java

@ -20,12 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class TbelCfTsGeofencingArg implements TbelCfArg {
public class TbelCfGeofencingArg implements TbelCfArg {
private final Object value;
@JsonCreator
public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) {
public TbelCfGeofencingArg(@JsonProperty("value") Object value) {
this.value = value;
}

42
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java

@ -0,0 +1,42 @@
/**
* 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.
*/
package org.thingsboard.script.api.tbel;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class TbelCfPropagationArg implements TbelCfArg {
private final Object value;
@JsonCreator
public TbelCfPropagationArg(@JsonProperty("value") Object value) {
this.value = value;
}
@Override
public String getType() {
return "PROPAGATION_CF_ARGUMENT_VALUE";
}
@Override
public long memorySize() {
return OBJ_SIZE;
}
}

3
dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java

@ -61,8 +61,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements
@Override
public CalculatedField save(CalculatedField calculatedField) {
CalculatedField oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId);
return doSave(calculatedField, oldCalculatedField);
return save(calculatedField, true);
}
@Override

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

@ -50,12 +50,14 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.entity.EntityService;
import org.thingsboard.server.dao.eventsourcing.RelationActionEvent;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.ConstraintValidator;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import org.thingsboard.server.dao.sql.relation.JpaRelationQueryExecutorService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import java.util.ArrayList;
import java.util.Collections;
@ -71,6 +73,7 @@ import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber;
/**
* Created by ashvayka on 28.04.17.
@ -85,6 +88,8 @@ public class BaseRelationService implements RelationService {
private final ApplicationEventPublisher eventPublisher;
private final JpaExecutorService executor;
private final JpaRelationQueryExecutorService relationsExecutor;
private final ApiLimitService apiLimitService;
protected ScheduledExecutorService timeoutExecutorService;
@Value("${sql.relations.query_timeout:20}")
@ -93,13 +98,14 @@ public class BaseRelationService implements RelationService {
public BaseRelationService(RelationDao relationDao, @Lazy EntityService entityService,
TbTransactionalCache<RelationCacheKey, RelationCacheValue> cache,
ApplicationEventPublisher eventPublisher, JpaExecutorService executor,
JpaRelationQueryExecutorService relationsExecutor) {
JpaRelationQueryExecutorService relationsExecutor, ApiLimitService apiLimitService) {
this.relationDao = relationDao;
this.entityService = entityService;
this.cache = cache;
this.eventPublisher = eventPublisher;
this.executor = executor;
this.relationsExecutor = relationsExecutor;
this.apiLimitService = apiLimitService;
}
@PostConstruct
@ -504,14 +510,18 @@ public class BaseRelationService implements RelationService {
log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery);
validateId(tenantId, id -> "Invalid tenant id: " + id);
validate(relationPathQuery);
int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument);
validatePositiveNumber(limit, "Max related entities limit for relation path query must be positive!");
if (relationPathQuery.levels().size() == 1) {
RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0);
return switch (relationPathLevel.direction()) {
var relationsFuture = switch (relationPathLevel.direction()) {
case FROM -> findByFromAndTypeAsync(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 ?
entityRelations.subList(0, limit) : entityRelations, MoreExecutors.directExecutor());
}
return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery));
return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit));
}
private void validate(EntityRelationPathQuery relationPathQuery) {

2
dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java

@ -72,6 +72,6 @@ public interface RelationDao {
List<EntityRelation> findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery, int limit);
}

18
dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java

@ -299,13 +299,16 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple
}
@Override
public List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query) {
public List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query, int limit) {
List<RelationPathLevel> levels = query.levels();
if (levels == null || levels.isEmpty()) {
return Collections.emptyList();
return List.of();
}
if (limit <= 0) {
return List.of();
}
String sql = buildRelationPathSql(query);
Object[] params = buildRelationPathParams(query);
Object[] params = buildRelationPathParams(query, limit);
log.trace("[{}] relation path query: {}", tenantId, sql);
@ -330,7 +333,7 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple
.collect(Collectors.toList());
}
private Object[] buildRelationPathParams(EntityRelationPathQuery query) {
private Object[] buildRelationPathParams(EntityRelationPathQuery query, int limit) {
final List<Object> params = new ArrayList<>();
// seed
params.add(query.rootEntityId().getId());
@ -340,6 +343,10 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple
for (var lvl : query.levels()) {
params.add(lvl.relationType());
}
// limit
params.add(limit);
return params.toArray();
}
@ -387,7 +394,8 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple
.append("FROM ").append(RELATION_TABLE_NAME).append(" r\n")
.append("JOIN ").append(prevForLast).append(" p ON ").append(lastJoin).append("\n")
.append("WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n")
.append(" AND r.relation_type = ?");
.append(" AND r.relation_type = ?\n")
.append("LIMIT ?");
return sb.toString();
}

117
dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java

@ -37,6 +37,7 @@ import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import java.util.ArrayList;
import java.util.Collections;
@ -52,6 +53,9 @@ public class RelationServiceTest extends AbstractServiceTest {
@Autowired
RelationService relationService;
@Autowired
private TbTenantProfileCache tbTenantProfileCache;
@Before
public void before() {
}
@ -628,48 +632,111 @@ public class RelationServiceTest extends AbstractServiceTest {
}
@Test
public void testFindByPathQuery() throws Exception {
public void testFindByPathQueryWithoutExceedingLimit() throws Exception {
/*
A
[firstLevel, TO] B
[secondLevel, TO] C
[thirdLevel, FROM] D
[thirdLevel, FROM] E
[thirdLevel, FROM] F
[thirdLevel, FROM] D1
[thirdLevel, FROM] D2
[thirdLevel, FROM] ...
[thirdLevel, FROM] D{N - 1}, where N is the limit
*/
// rootEntity
AssetId assetA = new AssetId(Uuids.timeBased());
// firstLevelEntity
AssetId assetB = new AssetId(Uuids.timeBased());
// secondLevelEntity
AssetId assetC = new AssetId(Uuids.timeBased());
// thirdLevelEntities
AssetId assetD = new AssetId(Uuids.timeBased());
AssetId assetE = new AssetId(Uuids.timeBased());
AssetId assetF = new AssetId(Uuids.timeBased());
EntityRelation firstLevelRelation = new EntityRelation(assetB, assetA, "firstLevel");
EntityRelation secondLevelRelation = new EntityRelation(assetC, assetB, "secondLevel");
EntityRelation thirdLevelRelation1 = new EntityRelation(assetC, assetD, "thirdLevel");
EntityRelation thirdLevelRelation2 = new EntityRelation(assetC, assetE, "thirdLevel");
EntityRelation thirdLevelRelation3 = new EntityRelation(assetC, assetF, "thirdLevel");
// create first and second level
saveRelation(new EntityRelation(assetB, assetA, "firstLevel"));
saveRelation(new EntityRelation(assetC, assetB, "secondLevel"));
firstLevelRelation = saveRelation(firstLevelRelation);
secondLevelRelation = saveRelation(secondLevelRelation);
thirdLevelRelation1 = saveRelation(thirdLevelRelation1);
thirdLevelRelation2 = saveRelation(thirdLevelRelation2);
thirdLevelRelation3 = saveRelation(thirdLevelRelation3);
int limit = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
.getMaxRelatedEntitiesToReturnPerCfArgument();
List<EntityRelation> expectedRelations = List.of(thirdLevelRelation1, thirdLevelRelation2, thirdLevelRelation3);
int totalCreated = limit - 1;
EntityRelationPathQuery relationPathQuery = new EntityRelationPathQuery(assetA, List.of(
List<EntityRelation> allThirdLevelRelations = new ArrayList<>();
for (int i = 0; i < totalCreated; i++) {
AssetId leaf = new AssetId(Uuids.timeBased());
allThirdLevelRelations.add(saveRelation(new EntityRelation(assetC, leaf, "thirdLevel")));
}
EntityRelationPathQuery query = new EntityRelationPathQuery(assetA, List.of(
new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"),
new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"),
new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel")
));
List<EntityRelation> entityRelations = relationService.findByRelationPathQueryAsync(tenantId, relationPathQuery).get();
assertThat(expectedRelations).containsExactlyInAnyOrderElementsOf(entityRelations);
// call a method that applies the default limit internally
List<EntityRelation> result = relationService.findByRelationPathQueryAsync(tenantId, query).get();
// verify that limit has been applied
assertThat(result).hasSize(totalCreated);
// verify all returned are valid third-level relations under C
assertThat(result)
.allSatisfy(rel -> {
assertThat(rel.getType()).isEqualTo("thirdLevel");
assertThat(rel.getFrom()).isEqualTo(assetC);
});
// verify the returned subset is part of all created relations
assertThat(result).isEqualTo(allThirdLevelRelations);
}
@Test
public void testFindByPathQueryWithExceedingLimit() throws Exception {
/*
A
[firstLevel, TO] B
[secondLevel, TO] C
[thirdLevel, FROM] D1
[thirdLevel, FROM] D2
[thirdLevel, FROM] ...
[thirdLevel, FROM] D{N + 20}, where N is the limit
*/
AssetId assetA = new AssetId(Uuids.timeBased());
AssetId assetB = new AssetId(Uuids.timeBased());
AssetId assetC = new AssetId(Uuids.timeBased());
// create first and second level
saveRelation(new EntityRelation(assetB, assetA, "firstLevel"));
saveRelation(new EntityRelation(assetC, assetB, "secondLevel"));
int limit = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
.getMaxRelatedEntitiesToReturnPerCfArgument();
int totalCreated = limit + 20;
List<EntityRelation> allThirdLevelRelations = new ArrayList<>();
for (int i = 0; i < totalCreated; i++) {
AssetId leaf = new AssetId(Uuids.timeBased());
allThirdLevelRelations.add(saveRelation(new EntityRelation(assetC, leaf, "thirdLevel")));
}
EntityRelationPathQuery query = new EntityRelationPathQuery(assetA, List.of(
new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"),
new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"),
new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel")
));
// call a method that applies the default limit internally
List<EntityRelation> result = relationService.findByRelationPathQueryAsync(tenantId, query).get();
// verify that limit has been applied
assertThat(result).hasSize(limit);
// verify all returned are valid third-level relations under C
assertThat(result)
.allSatisfy(rel -> {
assertThat(rel.getType()).isEqualTo("thirdLevel");
assertThat(rel.getFrom()).isEqualTo(assetC);
});
// verify the returned subset is part of all created relations
assertThat(result).isSubsetOf(allThirdLevelRelations);
}
@Test

44
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java

@ -266,6 +266,33 @@ public class TestRestClient {
.as(ArrayNode.class);
}
public ValidatableResponse deleteEntityAttributes(EntityId entityId, AttributeScope scope, String keys) {
Map<String, String> pathParams = new HashMap<>();
pathParams.put("entityId", entityId.getId().toString());
pathParams.put("entityType", entityId.getEntityType().name());
pathParams.put("scope", scope.name());
return given().spec(requestSpec)
.pathParams(pathParams)
.queryParam("keys", keys)
.delete("/api/plugins/telemetry/{entityType}/{entityId}/{scope}")
.then()
.statusCode(HTTP_OK);
}
public ValidatableResponse deleteEntityTimeseries(EntityId entityId, String keys, boolean deleteAllDataForKeys) {
Map<String, String> pathParams = new HashMap<>();
pathParams.put("entityType", entityId.getEntityType().name());
pathParams.put("entityId", entityId.getId().toString());
return given().spec(requestSpec)
.pathParams(pathParams)
.queryParam("keys", keys)
.queryParam("deleteAllDataForKeys", Boolean.toString(deleteAllDataForKeys))
.delete("/api/plugins/telemetry/{entityType}/{entityId}/timeseries/delete")
.then()
.statusCode(HTTP_OK);
}
public JsonNode getLatestTelemetry(EntityId entityId) {
return given().spec(requestSpec)
.get("/api/plugins/telemetry/" + entityId.getEntityType().name() + "/" + entityId.getId() + "/values/timeseries")
@ -378,6 +405,23 @@ public class TestRestClient {
.as(EntityRelation.class);
}
public EntityRelation deleteEntityRelation(EntityId fromId, String relationType, EntityId toId) {
Map<String, String> queryParams = new HashMap<>();
queryParams.put("fromId", fromId.getId().toString());
queryParams.put("fromType", fromId.getEntityType().name());
queryParams.put("relationType", relationType);
queryParams.put("toId", toId.getId().toString());
queryParams.put("toType", toId.getEntityType().name());
return given().spec(requestSpec)
.queryParams(queryParams)
.delete("/api/v2/relation")
.then()
.statusCode(HTTP_OK)
.extract()
.as(EntityRelation.class);
}
public JsonNode postServerSideRpc(DeviceId deviceId, JsonNode serverRpcPayload) {
return given().spec(requestSpec)
.body(serverRpcPayload)

183
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java

@ -23,6 +23,7 @@ import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.asset.Asset;
@ -32,6 +33,7 @@ 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.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
@ -419,6 +421,179 @@ public class CalculatedFieldTest extends AbstractContainerTest {
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
}
@Test
public void testPropagationCalculatedField_withExpression() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// --- Arrange entities ---
String deviceToken = "propagationDeviceTokenA";
Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device With Expression", deviceProfileId));
Asset asset1 = testRestClient.postAsset(createAsset("Propagated Asset 1", null));
Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 2", null));
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
testRestClient.postEntityRelation(rel1);
testRestClient.postEntityRelation(rel2);
// Telemetry on device
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":12.5}"));
// --- Build CF: PROPAGATION with expression ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.PROPAGATION);
cf.setName("Propagation CF (expr)");
cf.setConfigurationVersion(1);
PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setApplyExpressionToResolvedArguments(true);
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
cfg.setArguments(Map.of("t", arg));
cfg.setExpression("{\"testResult\": t * 2}");
Output output = new Output();
output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
cfg.setOutput(output);
cf.setConfiguration(cfg);
CalculatedField saved = testRestClient.postCalculatedField(cf);
// --- Assert propagated calculation (expression applied) ---
await().alias("propagation expr mode evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs1 = testRestClient.getAttributes(asset1.getId(), SERVER_SCOPE, "testResult");
assertThat(attrs1).isNotNull().hasSize(1);
Map<String, Integer> m1 = intKv(attrs1);
assertThat(m1).containsEntry("testResult", 25);
ArrayNode attrs2 = testRestClient.getAttributes(asset2.getId(), SERVER_SCOPE, "testResult");
assertThat(attrs2).isNotNull().hasSize(1);
Map<String, Integer> m2 = intKv(attrs2);
assertThat(m2).containsEntry("testResult", 25);
});
testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId());
testRestClient.deleteEntityAttributes(asset1.getId(), SERVER_SCOPE, "testResult");
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
// --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) ---
await().alias("propagation expr mode evaluation after temperature update")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs1 = testRestClient.getAttributes(asset1.getId(), SERVER_SCOPE, "testResult");
assertThat(attrs1).isNullOrEmpty();
ArrayNode attrs2 = testRestClient.getAttributes(asset2.getId(), SERVER_SCOPE, "testResult");
assertThat(attrs2).isNotNull().hasSize(1);
Map<String, Integer> m2 = intKv(attrs2);
assertThat(m2).containsEntry("testResult", 50);
});
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
}
@Test
public void testPropagationCalculatedField_withoutExpression() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// --- Arrange entities ---
String deviceToken = "propagationDeviceTokenB";
Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device Without Expression", deviceProfileId));
Asset asset1 = testRestClient.postAsset(createAsset("Propagated Asset 3", null));
Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 4", null));
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
testRestClient.postEntityRelation(rel1);
testRestClient.postEntityRelation(rel2);
// Telemetry on device
long ts = System.currentTimeMillis() - 300000L;
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts)));
// --- Build CF: PROPAGATION without expression ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.PROPAGATION);
cf.setName("Propagation CF (args-only)");
cf.setConfigurationVersion(1);
PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
cfg.setArguments(Map.of("temperatureComputed", arg));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
cfg.setOutput(output);
cf.setConfiguration(cfg);
CalculatedField saved = testRestClient.postCalculatedField(cf);
// --- Assert propagated calculation (arguments-only mode) ---
await().alias("propagation args-only evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId());
assertThat(temperature1).isNotNull();
assertThat(temperature1.get("temperatureComputed")).isNotNull();
assertThat(temperature1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
assertThat(temperature1.get("temperatureComputed").get(0).get("value").asText()).isEqualTo("12.5");
JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId());
assertThat(temperature2).isNotNull();
assertThat(temperature2.get("temperatureComputed")).isNotNull();
assertThat(temperature2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
assertThat(temperature2.get("temperatureComputed").get(0).get("value").asText()).isEqualTo("12.5");
});
testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId());
testRestClient.deleteEntityTimeseries(asset1.getId(), "temperatureComputed", true);
// Update telemetry on device
long newTs = System.currentTimeMillis() - 300000L;
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs)));
// --- Assert propagated calculation (arguments-only mode after update) ---
await().alias("propagation args-only evaluation after temperature update")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId());
assertThat(temperature1).isNullOrEmpty();
JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId());
assertThat(temperature2).isNotNull();
assertThat(temperature2.get("temperatureComputed")).isNotNull();
assertThat(temperature2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs));
assertThat(temperature2.get("temperatureComputed").get(0).get("value").asInt()).isEqualTo(25);
});
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
}
private CalculatedField createSimpleCalculatedField() {
return createSimpleCalculatedField(device.getId());
}
@ -514,4 +689,12 @@ public class CalculatedFieldTest extends AbstractContainerTest {
return m;
}
private static Map<String, Integer> intKv(ArrayNode attrs) {
Map<String, Integer> m = new HashMap<>();
for (JsonNode n : attrs) {
m.put(n.get("key").asText(), n.get("value").asInt());
}
return m;
}
}

61
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts

@ -0,0 +1,61 @@
///
/// 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 {
CalculatedFieldDialogComponent
} from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component';
import {
CalculatedFieldScriptTestDialogComponent
} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component';
import {
CalculatedFieldTestArgumentsComponent
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import {
EntityDebugSettingsButtonComponent
} from '@home/components/entity/debug/entity-debug-settings-button.component';
import {
GeofencingConfigurationModule
} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module';
import {
SimpleConfigurationModule
} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.module';
import {
PropagationConfigurationModule
} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module';
@NgModule({
declarations: [
CalculatedFieldDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
],
imports: [
CommonModule,
SharedModule,
GeofencingConfigurationModule,
EntityDebugSettingsButtonComponent,
SimpleConfigurationModule,
PropagationConfigurationModule,
],
exports: [
CalculatedFieldDialogComponent,
CalculatedFieldScriptTestDialogComponent,
]
})
export class CalculatedFieldsModule {}

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

@ -40,10 +40,12 @@ import {
ArgumentType,
CalculatedField,
CalculatedFieldEventArguments,
CalculatedFieldScriptConfiguration,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
PropagationWithExpression,
} from '@shared/models/calculated-field.models';
import {
CalculatedFieldDebugDialogComponent,
@ -122,7 +124,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
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>('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '80px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(expressionColumn);
this.cellActionDescriptors.push(
@ -156,11 +158,13 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
private getExpressionLabel(entity: CalculatedField): string {
if (entity.type === CalculatedFieldType.SCRIPT) {
if (entity.type === CalculatedFieldType.SCRIPT ||
entity.type === CalculatedFieldType.PROPAGATION && entity.configuration.applyExpressionToResolvedArguments === true) {
return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')';
} else {
return entity.configuration?.expression ?? '';
} else if (entity.type === CalculatedFieldType.SIMPLE) {
return entity.configuration.expression ?? '';
}
return '';
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
@ -287,32 +291,42 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable<string> {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const type = calculatedField.configuration.arguments[key].refEntityKey.type;
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key)
? { ...argumentsObj[key], type }
: type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() };
return acc;
}, {});
return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'],
data: {
arguments: resultArguments,
expression: calculatedField.configuration.expression,
argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments),
argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments),
openCalculatedFieldEdit
}
}).afterClosed()
.pipe(
filter(Boolean),
tap(expression => {
if (openCalculatedFieldEdit) {
this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true)
if (
calculatedField.type === CalculatedFieldType.SCRIPT ||
(calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true)
) {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const type = calculatedField.configuration.arguments[key].refEntityKey.type;
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key)
? {...argumentsObj[key], type}
: type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()};
return acc;
}, {});
return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'],
data: {
arguments: resultArguments,
expression: (calculatedField.configuration as CalculatedFieldScriptConfiguration | PropagationWithExpression).expression,
argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments),
argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments),
openCalculatedFieldEdit
}
}),
);
}).afterClosed()
.pipe(
filter(Boolean),
tap(expression => {
if (openCalculatedFieldEdit) {
this.editCalculatedField({
entityId: this.entityId, ...calculatedField,
configuration: {...calculatedField.configuration, expression} as any
}, true)
}
}),
);
} else {
return of(null);
}
}
}

134
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html

@ -19,62 +19,34 @@
<div class="tb-form-panel no-border no-padding mb-2">
<div class="tb-form-panel-title">{{ 'calculated-fields.argument-settings' | translate }}</div>
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-duplicate' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-pattern' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-max-length' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-forbidden' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<ng-container [formGroup]="refEntityIdFormGroup">
@if (!isOutputKey) {
<ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
label: 'calculated-fields.argument-name',
required: 'calculated-fields.hint.argument-name-required',
duplicate: 'calculated-fields.hint.argument-name-duplicate',
pattern: 'calculated-fields.hint.argument-name-pattern',
maxlength: 'calculated-fields.hint.argument-name-max-length',
forbidden: 'calculated-fields.hint.argument-name-forbidden'
}"></ng-container>
}
<ng-container>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType">
<mat-select [formControl]="argumentType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
@if (argumentType.touched && argumentType.hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.entity-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
@ -83,7 +55,8 @@
<tb-entity-autocomplete
class="flex flex-1"
#entityAutocomplete
formControlName="id"
formControlName="refEntityId"
useFullEntityId
inlineField
[placeholder]="'action.set' | translate"
[required]="true"
@ -158,6 +131,16 @@
}
}
</ng-container>
@if (isOutputKey) {
<ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
label: 'calculated-fields.output-key',
required: 'calculated-fields.hint.output-key-required',
duplicate: 'calculated-fields.hint.output-key-duplicate',
pattern: 'calculated-fields.hint.output-key-pattern',
maxlength: 'calculated-fields.hint.output-key-max-length',
forbidden: 'calculated-fields.hint.output-key-forbidden'
}"></ng-container>
}
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.default-value' | translate }}</div>
@ -207,3 +190,54 @@
</button>
</div>
</div>
<ng-template #argumentNameTemplate let-label="label" let-required="required" let-duplicate="duplicate"
let-pattern="pattern" let-maxlength="maxlength" let-forbidden="forbidden">
<div class="tb-form-row" [formGroup]="argumentFormGroup">
<div class="fixed-title-width tb-required">{{ label | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="required | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="duplicate | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="pattern | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="maxlength | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="forbidden | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
</ng-template>

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss

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

@ -14,7 +14,16 @@
/// limitations under the License.
///
import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core';
import {
AfterViewInit,
ChangeDetectorRef,
Component,
DestroyRef,
Input,
OnInit,
output,
ViewChild
} from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
@ -25,7 +34,6 @@ import {
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentValue,
CalculatedFieldType,
getCalculatedFieldCurrentEntityFilter
} from '@shared/models/calculated-field.models';
import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators';
@ -43,6 +51,7 @@ import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { TenantId } from '@shared/models/id/tenant-id';
@Component({
selector: 'tb-calculated-field-argument-panel',
@ -56,22 +65,23 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType;
@Input() isScript: boolean;
@Input() usedArgumentNames: string[];
@Input() isOutputKey = false;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
argumentsDataApplied = output<CalculatedFieldArgumentValue>();
argumentType = this.fb.control(ArgumentEntityType.Current, Validators.required);
readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg;
readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10);
argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({
entityType: [ArgumentEntityType.Current],
id: ['']
}),
refEntityId: [null],
refEntityKey: this.fb.group({
type: [ArgumentType.LatestTelemetry, [Validators.required]],
key: ['', [Validators.pattern(oneSpaceInsideRegex)]],
@ -86,7 +96,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null);
readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly ArgumentType = ArgumentType;
readonly DataKeyType = DataKeyType;
@ -103,20 +112,17 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private fb: FormBuilder,
private cd: ChangeDetectorRef,
private popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>,
private store: Store<AppState>
private store: Store<AppState>,
private destroyRef: DestroyRef
) {
this.observeEntityFilterChanges();
this.observeEntityTypeChanges();
this.observeArgumentTypeChanges();
this.observeEntityKeyChanges();
this.observeUpdatePosition();
}
get entityType(): ArgumentEntityType {
return this.argumentFormGroup.get('refEntityId').get('entityType').value;
}
get refEntityIdFormGroup(): FormGroup {
return this.argumentFormGroup.get('refEntityId') as FormGroup;
return this.argumentType.value;
}
get refEntityKeyFormGroup(): FormGroup {
@ -130,14 +136,18 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
ngOnInit(): void {
this.updatedArgumentType();
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
this.updateEntityFilter(this.argument.refEntityId?.entityType, true);
this.updateEntityFilter(this.entityType, true);
this.updatedRefEntityIdState(this.entityType);
this.toggleByEntityKeyType(this.argument.refEntityKey?.type);
this.setInitialEntityKeyType();
this.setInitialEntityType();
this.setWatchKeyChange();
this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT);
.filter(type => type !== ArgumentType.Rolling || this.isScript);
}
ngAfterViewInit(): void {
@ -147,12 +157,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
saveArgument(): void {
const { refEntityId, ...restConfig } = this.argumentFormGroup.value;
const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue;
if (refEntityId.entityType === ArgumentEntityType.Tenant) {
refEntityId.id = this.tenantId;
const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue;
if (this.entityType === ArgumentEntityType.Tenant) {
value.refEntityId = new TenantId(this.tenantId) as any;
}
if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) {
if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) {
value.entityName = this.entityNameSubject.value;
}
if (value.defaultValue) {
@ -166,6 +175,14 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.popover.hide();
}
private updatedArgumentType(): void {
let argumentType = ArgumentEntityType.Current;
if (this.argument.refEntityId?.entityType) {
argumentType = this.argument.refEntityId.entityType;
}
this.argumentType.setValue(argumentType, {emitEvent: false});
}
private toggleByEntityKeyType(type: ArgumentType): void {
const isAttribute = type === ArgumentType.Attribute;
const isRolling = type === ArgumentType.Rolling;
@ -205,26 +222,21 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private observeEntityFilterChanges(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.argumentType.valueChanges,
this.refEntityKeyFormGroup.get('type').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)),
this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)),
this.refEntityKeyFormGroup.get('scope').valueChanges,
)
.pipe(debounceTime(50), takeUntilDestroyed())
.subscribe(() => this.updateEntityFilter(this.entityType));
}
private observeEntityTypeChanges(): void {
this.refEntityIdFormGroup.get('entityType').valueChanges
private observeArgumentTypeChanges(): void {
this.argumentType.valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => {
this.argumentFormGroup.get('refEntityId').get('id').setValue('');
const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current;
this.argumentFormGroup.get('refEntityId')
.get('id')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
this.argumentFormGroup.get('refEntityId').setValue(null);
this.updatedRefEntityIdState(type);
if (!this.enableAttributeScopeSelection) {
this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE);
}
@ -247,29 +259,56 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
private setInitialEntityKeyType(): void {
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) {
if (!this.isScript && this.argument.refEntityKey?.type === ArgumentType.Rolling) {
const typeControl = this.argumentFormGroup.get('refEntityKey').get('type');
typeControl.setValue(null);
typeControl.markAsTouched();
}
}
private setInitialEntityType() {
if (!this.argumentEntityTypes.includes(this.entityType)) {
this.argumentType.setValue(null);
this.argumentType.markAsTouched();
}
}
private setWatchKeyChange(): void {
if (this.isOutputKey) {
this.refEntityKeyFormGroup.get('key').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((key) => {
if (this.argumentFormGroup.get('argumentName').pristine) {
this.argumentFormGroup.get('argumentName').setValue(key);
}
});
}
}
private forbiddenArgumentNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenArgumentNames = ['ctx', 'e', 'pi'];
const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx'];
return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
private observeUpdatePosition(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.argumentType.valueChanges,
this.refEntityKeyFormGroup.get('type').valueChanges,
this.argumentFormGroup.get('timeWindow').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)),
this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)),
)
.pipe(delay(50), takeUntilDestroyed())
.subscribe(() => this.popover.updatePosition());
}
private updatedRefEntityIdState(type: ArgumentEntityType): void {
const isEntityWithId = !!type && type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current;
this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
}
}

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

@ -21,7 +21,7 @@
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2">
<div tbTruncateWithTooltip>{{ 'common.name' | translate }}</div>
<div tbTruncateWithTooltip>{{ argumentNameColumn | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="argument-name-cell w-1/3 xs:w-1/2">
<div class="flex items-center">
@ -29,7 +29,7 @@
<tb-copy-button
class="copy-argument-name"
[copyText]="argument.argumentName"
tooltipText="{{ 'calculated-fields.copy-argument-name' | translate }}"
tooltipText="{{ argumentNameColumnCopy | translate }}"
tooltipPosition="above"
icon="content_copy"
/>
@ -37,7 +37,7 @@
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'entityType'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 xs:hidden">
{{ 'entity.entity-type' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/5 xs:hidden">
@ -96,8 +96,7 @@
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling
&& calculatedFieldType === CalculatedFieldType.SIMPLE) && argument.refEntityId?.id !== NULL_UUID"
[matBadgeHidden]="isEditButtonShowBadge(argument)"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*"
@ -115,10 +114,8 @@
</div>
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select"
*matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-header-row>
<mat-row
*matRowDef="let argument; columns: ['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-row>
<mat-header-row class="mat-row-select" *matHeaderRowDef="displayColumns"></mat-header-row>
<mat-row *matRowDef="let argument; columns: displayColumns"></mat-row>
</table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center">

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

@ -62,7 +62,7 @@
}
.arguments-table {
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header {
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell:nth-child(2) {
padding: 0 28px 0 0;
}
}

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

@ -43,17 +43,19 @@ import {
CalculatedFieldArgumentValue,
CalculatedFieldType,
} from '@shared/models/calculated-field.models';
import { CalculatedFieldArgumentPanelComponent } from '@home/components/calculated-fields/components/public-api';
import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL, isEqual } from '@core/utils';
import { getEntityDetailsPageURL, isDefined, isEqual } from '@core/utils';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract';
import { EntityService } from '@core/http/entity.service';
import { MatSort } from '@angular/material/sort';
import { MatSort, SortDirection } from '@angular/material/sort';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -83,16 +85,22 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType;
@Input() isScript: boolean;
@ViewChild(MatSort, { static: true }) sort: MatSort;
errorText = '';
argumentsFormArray = this.fb.array<CalculatedFieldArgumentValue>([]);
entityNameMap = new Map<string, string>();
sortOrder = { direction: 'asc', property: '' };
sortOrder: { direction: SortDirection; property: string } = {direction: 'asc', property: ''};
dataSource = new CalculatedFieldArgumentDatasource();
argumentNameColumn = 'common.name';
argumentNameColumnCopy = 'calculated-fields.copy-argument-name';
displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions'];
protected panelAdditionalCtx: Record<string, any>
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly ArgumentEntityType = ArgumentEntityType;
@ -105,14 +113,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
private propagateChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {};
constructor(
private fb: FormBuilder,
private popoverService: TbPopoverService,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef,
private renderer: Renderer2,
private entityService: EntityService,
private destroyRef: DestroyRef,
private store: Store<AppState>
protected fb: FormBuilder,
protected popoverService: TbPopoverService,
protected viewContainerRef: ViewContainerRef,
protected cd: ChangeDetectorRef,
protected renderer: Renderer2,
protected entityService: EntityService,
protected destroyRef: DestroyRef,
protected store: Store<AppState>
) {
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
this.updateDataSource(value);
@ -121,9 +129,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.calculatedFieldType?.previousValue
&& changes.calculatedFieldType.currentValue !== changes.calculatedFieldType.previousValue) {
this.argumentsFormArray.updateValueAndValidity();
if (isDefined(changes.isScript?.previousValue) && changes.isScript.currentValue !== changes.isScript.previousValue) {
this.changeIsScriptMode();
}
}
@ -139,7 +146,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.propagateChange = fn;
}
registerOnTouched(_): void {}
registerOnTouched(_: any): void {}
validate(): ValidationErrors | null {
this.updateErrorText();
@ -168,7 +175,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
index,
argument,
entityId: this.entityId,
calculatedFieldType: this.calculatedFieldType,
isScript: this.isScript,
buttonTitle: isExists ? 'action.apply' : 'action.add',
tenantId: this.tenantId,
entityName: this.entityName,
@ -179,8 +186,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
renderer: this.renderer,
componentType: CalculatedFieldArgumentPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'],
context: ctx,
preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'],
context: Object.assign(ctx, this.panelAdditionalCtx),
isModal: true
});
this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ entityName, ...value }) => {
@ -203,9 +210,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.dataSource.loadData(sortedValue);
}
private updateErrorText(): void {
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE
&& this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
protected updateErrorText(): void {
if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling';
} else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
@ -234,6 +240,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
return getEntityDetailsPageURL(id, type);
}
protected changeIsScriptMode(): void {
this.argumentsFormArray.updateValueAndValidity();
}
protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean {
return !(argument.refEntityKey.type === ArgumentType.Rolling && !this.isScript) && argument.refEntityId?.id !== NULL_UUID
}
private populateArgumentsFormArray(argumentsObj: Record<string, CalculatedFieldArgument>): void {
Object.keys(argumentsObj).forEach(key => {
const value: CalculatedFieldArgumentValue = {

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

@ -0,0 +1,45 @@
///
/// 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 {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component';
import {
PropagateArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
],
declarations: [
CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent
],
exports: [
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent
]
})
export class CalculatedFieldArgumentsTableModule {}

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

@ -0,0 +1,116 @@
///
/// 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,
DestroyRef,
forwardRef,
OnInit,
Renderer2,
ViewContainerRef,
} from '@angular/core';
import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms';
import { TbPopoverService } from '@shared/components/popover.service';
import { EntityService } from '@core/http/entity.service';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component';
import { ArgumentEntityType, ArgumentType, CalculatedFieldArgumentValue } from '@shared/models/calculated-field.models';
import { isDefined } from '@core/utils';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@Component({
selector: 'tb-propagate-arguments-table',
templateUrl: './calculated-field-arguments-table.component.html',
styleUrls: [`calculated-field-arguments-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PropagateArgumentsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => PropagateArgumentsTableComponent),
multi: true
}
],
})
export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent implements OnInit {
constructor(
protected fb: FormBuilder,
protected popoverService: TbPopoverService,
protected viewContainerRef: ViewContainerRef,
protected cd: ChangeDetectorRef,
protected renderer: Renderer2,
protected entityService: EntityService,
protected destroyRef: DestroyRef,
protected store: Store<AppState>
) {
super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store)
}
ngOnInit() {
this.updatedValue();
}
protected changeIsScriptMode(): void {
this.updatedValue();
super.changeIsScriptMode();
}
private updatedValue() {
if (this.isScript) {
this.argumentNameColumn = 'common.name';
this.argumentNameColumnCopy = 'calculated-fields.copy-argument-name';
this.displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions'];
this.panelAdditionalCtx = null;
} else {
this.argumentNameColumn = 'calculated-fields.output-key';
this.argumentNameColumnCopy = 'calculated-fields.copy-output-key';
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
argumentEntityTypes: [ArgumentEntityType.Current],
isOutputKey: true
};
}
}
protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean {
if (!this.isScript && isDefined(argument?.refEntityId)) {
return false;
}
return super.isEditButtonShowBadge(argument);
}
protected updateErrorText(): void {
if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId))) {
this.errorText = 'calculated-fields.hint.arguments-propagate-argument-entity-type';
} else if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-propagate-arguments-with-rolling';
} else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
} else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty';
} else {
this.errorText = '';
}
}
}

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

@ -62,187 +62,30 @@
</mat-select>
</mat-form-field>
</div>
<ng-container [formGroup]="configFormGroup">
@if (fieldFormGroup.get('type').value !== CalculatedFieldType.GEOFENCING) {
<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"
[entityName]="data.entityName"
[calculatedFieldType]="fieldFormGroup.get('type').value"/>
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }}
</div>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SIMPLE">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'" required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SCRIPT">
<tb-js-func required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="configFormGroup.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
</div>
</div>
} @else {
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-coordinates' | translate }}">
{{ 'calculated-fields.entity-coordinates' | translate }}
</div>
<div class="flex items-start gap-3" [formGroup]="coordinatesFormGroup">
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.latitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.latitude-time-series-key-required' | translate }}"
formControlName="latitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.longitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.longitude-time-series-key-required' | translate }}"
formControlName="longitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.geofencing-zone-groups' | translate }}">
{{ 'calculated-fields.geofencing-zone-groups' | translate }}
</div>
<tb-calculated-field-geofencing-zone-groups-table formControlName="zoneGroups"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<mat-slide-toggle class="mat-slide" formControlName="scheduledUpdateEnabled">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">
{{ 'calculated-fields.zone-group-refresh-interval' | translate }}
</div>
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate: {min: minAllowedScheduledUpdateIntervalInSecForCF} }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>
</div>
</div>
</div>
@switch (fieldFormGroup.get('type').value) {
@case (CalculatedFieldType.GEOFENCING) {
<tb-geofencing-configuration formControlName="configuration" [entityId]="data.entityId" [entityName]="data.entityName" [tenantId]="data.tenantId">
</tb-geofencing-configuration>
}
<div class="tb-form-panel" [formGroup]="outputFormGroup">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex items-center gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
<mat-select formControlName="type">
@for (type of outputTypes; track type) {
<mat-option [value]="type">{{ OutputTypeTranslations.get(type) | translate}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (outputFormGroup.get('type').value === OutputType.Attribute
&& (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.attribute-scope' | translate }}</mat-label>
<mat-select formControlName="scope" class="w-full">
<mat-option [value]="AttributeScope.SERVER_SCOPE">
{{ 'calculated-fields.server-attributes' | translate }}
</mat-option>
<mat-option [value]="AttributeScope.SHARED_SCOPE">
{{ 'calculated-fields.shared-attributes' | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
</div>
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) {
<div class="flex items-start gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{ (outputFormGroup.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate }}
</mat-label>
<input matInput formControlName="name" required>
@if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) {
<mat-error>
@if (outputFormGroup.get('name').hasError('required')) {
{{ 'common.hint.key-required' | translate }}
} @else if (outputFormGroup.get('name').hasError('pattern')) {
{{ 'common.hint.key-pattern' | translate }}
} @else if (outputFormGroup.get('name').hasError('maxlength')) {
{{ 'common.hint.key-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
<input matInput type="number" formControlName="decimalsByDefault">
@if (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) {
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
</div>
<div class="tb-form-row" [formGroup]="configFormGroup" *ngIf="outputFormGroup.get('type').value === OutputType.Timeseries">
<mat-slide-toggle class="mat-slide" formControlName="useLatestTs">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}" translate>
calculated-fields.use-latest-timestamp
</div>
</mat-slide-toggle>
</div>
}
</div>
</ng-container>
@case (CalculatedFieldType.PROPAGATION) {
<tb-propagation-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId"
[testScript]="onTestScript.bind(this)">
</tb-propagation-configuration>
}
@default {
<tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId"
[isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT"
[testScript]="onTestScript.bind(this)"
>
</tb-simple-configuration>
}
}
</div>
</div>
<div mat-dialog-actions class="justify-end">

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

@ -18,36 +18,25 @@ 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 { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import {
ArgumentEntityType,
CalculatedField,
CalculatedFieldConfiguration,
calculatedFieldDefaultScript,
CalculatedFieldGeofencing,
CalculatedFieldTestScriptFn,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
getCalculatedFieldCurrentEntityFilter,
OutputType,
OutputTypeTranslations
CalculatedFieldTypeTranslations
} from '@shared/models/calculated-field.models';
import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { EntityType } from '@shared/models/entity-type.models';
import { map, startWith, switchMap } from 'rxjs/operators';
import { switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { Observable } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { EntityFilter } from '@shared/models/query/query.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { deepTrim } from '@core/utils';
export interface CalculatedFieldDialogData {
value?: CalculatedField;
@ -68,70 +57,22 @@ export interface CalculatedFieldDialogData {
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF;
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.SIMPLE],
debugSettings: [],
configuration: this.fb.group({
entityCoordinates: this.fb.group({
latitudeKeyName: [null, [Validators.required]],
longitudeKeyName: [null, [Validators.required]],
}),
arguments: this.fb.control({}),
zoneGroups: this.fb.control({}),
scheduledUpdateEnabled: [true],
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }],
type: [OutputType.Timeseries],
decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]],
}),
useLatestTs: [false]
}),
configuration: this.fb.control<CalculatedFieldConfiguration>({} as CalculatedFieldConfiguration),
});
functionArgs$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj))
);
argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig,
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null;
currentEntityFilter: EntityFilter;
isRelatedEntity: boolean;
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
readonly EntityType = EntityType;
readonly CalculatedFieldType = CalculatedFieldType;
readonly ScriptLanguage = ScriptLanguage;
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[];
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
readonly DataKeyType = DataKeyType;
constructor(protected store: Store<AppState>,
protected router: Router,
@ -142,49 +83,12 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
private fb: FormBuilder) {
super(store, router, dialogRef);
this.observeIsLoading();
this.observeType();
this.applyDialogData();
this.observeTypeChanges();
this.observeZoneChanges();
this.observeScheduledUpdateEnabled();
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.data.entityName, this.data.entityId);
}
get configFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration') as FormGroup;
}
get outputFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('output') as FormGroup;
}
get coordinatesFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('entityCoordinates') as FormGroup;
}
get fromGroupValue(): CalculatedField {
const { configuration, type, name, ...rest } = this.fieldFormGroup.value;
const { expressionSIMPLE, expressionSCRIPT, output, ...restConfig } = configuration;
let cf: CalculatedField = {
name: name.trim(),
type,
...rest
} as CalculatedField;
if (type !== CalculatedFieldType.GEOFENCING) {
cf.configuration = {
...restConfig,
type,
expression: configuration['expression'+type].trim(),
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
} else {
cf.configuration = {
...restConfig,
type,
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
delete cf.configuration.arguments;
}
return cf;
return deepTrim(this.fieldFormGroup.value as CalculatedField);
}
cancel(): void {
@ -199,12 +103,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
}
onTestScript(): void {
onTestScript(): Observable<string> {
const calculatedFieldId = this.data.value?.id?.id;
let testScriptDialogResult$: Observable<string>;
if (calculatedFieldId) {
testScriptDialogResult$ = this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId)
return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId)
.pipe(
switchMap(event => {
const args = event?.arguments ? JSON.parse(event.arguments) : null;
@ -212,114 +114,13 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}),
takeUntilDestroyed(this.destroyRef)
)
} else {
testScriptDialogResult$ = this.data.getTestScriptDialogFn(this.fromGroupValue, null, false);
}
testScriptDialogResult$.subscribe(expression => {
this.configFormGroup.get('expressionSCRIPT').setValue(expression);
this.configFormGroup.get('expressionSCRIPT').markAsDirty();
});
return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false);
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration;
const updatedConfig = { ...restConfig , ['expression'+type]: expression };
this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false});
}
private observeTypeChanges(): void {
this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value);
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
this.outputFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleScopeByOutputType(type));
this.fieldFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleKeyByCalculatedFieldType(type));
}
private observeZoneChanges(): void {
this.configFormGroup.get('zoneGroups').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((zoneGroups: CalculatedFieldGeofencing) =>
this.checkRelatedEntity(zoneGroups)
);
this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value);
}
private observeScheduledUpdateEnabled(): void {
this.configFormGroup.get('scheduledUpdateEnabled').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value: boolean) =>
this.checkScheduledUpdateEnabled(value)
);
this.checkScheduledUpdateEnabled(this.configFormGroup.get('scheduledUpdateEnabled').value);
}
private checkScheduledUpdateEnabled(value: boolean) {
if (value) {
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
} else {
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
}
}
private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}
private toggleScopeByOutputType(type: OutputType): void {
if (type === OutputType.Attribute) {
this.outputFormGroup.get('scope').enable({emitEvent: false});
} else {
this.outputFormGroup.get('scope').disable({emitEvent: false});
}
if (this.fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) {
if (type === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
}
}
private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void {
if (type === CalculatedFieldType.GEOFENCING) {
this.configFormGroup.get('entityCoordinates').enable({emitEvent: false});
this.configFormGroup.get('zoneGroups').enable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
this.configFormGroup.get('arguments').disable({emitEvent: false});
} else {
this.configFormGroup.get('entityCoordinates').disable({emitEvent: false});
this.configFormGroup.get('zoneGroups').disable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
if (type === CalculatedFieldType.SIMPLE) {
this.outputFormGroup.get('name').enable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
if (this.outputFormGroup.get('type').value === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
}
}
const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false});
}
private observeIsLoading(): void {
@ -328,12 +129,20 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.fieldFormGroup.disable({emitEvent: false});
} else {
this.fieldFormGroup.enable({emitEvent: false});
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value);
if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty();
}
}
});
}
private observeType(): void {
this.fieldFormGroup.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((type) => {
if (type !== CalculatedFieldType.SIMPLE && type !== CalculatedFieldType.SCRIPT) {
this.fieldFormGroup.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false});
}
});
}
}

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

@ -176,7 +176,7 @@
</div>
</ng-container>
<ng-container>
@if (entityFilter.singleEntity.id) {
@if (entityFilter.singleEntity?.id) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required" tb-hint-tooltip-icon="{{'calculated-fields.hint.perimeter-attribute-key' | translate}}">
{{ 'calculated-fields.perimeter-attribute-key' | translate }}

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts

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

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.scss

8
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts

@ -58,7 +58,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid';
import { BaseData } from '@shared/models/base-data';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component';
} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component';
@Component({
selector: 'tb-calculated-field-geofencing-zone-groups-table',
@ -79,9 +79,9 @@ import {
})
export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@Input({required: true}) entityId: EntityId;
@Input({required: true}) tenantId: string;
@Input({required: true}) entityName: string;
@ViewChild(MatSort, { static: true }) sort: MatSort;

68
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html

@ -0,0 +1,68 @@
<!--
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]="geofencingConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-coordinates' | translate }}">
{{ 'calculated-fields.entity-coordinates' | translate }}
</div>
<div class="flex items-start gap-3" formGroupName="entityCoordinates">
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.latitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.latitude-time-series-key-required' | translate }}"
formControlName="latitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.longitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.longitude-time-series-key-required' | translate }}"
formControlName="longitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.geofencing-zone-groups' | translate }}">
{{ 'calculated-fields.geofencing-zone-groups' | translate }}
</div>
<tb-calculated-field-geofencing-zone-groups-table formControlName="zoneGroups"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<mat-slide-toggle class="mat-slide" formControlName="scheduledUpdateEnabled">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">
{{ 'calculated-fields.zone-group-refresh-interval' | translate }}
</div>
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate: {min: minAllowedScheduledUpdateIntervalInSecForCF} }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>
</div>
</div>
</div>
<tb-calculate-field-output
formControlName="output"
[entityId]="entityId">
</tb-calculate-field-output>
</div>

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

@ -0,0 +1,157 @@
///
/// 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, forwardRef, Input, OnInit } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {
ArgumentEntityType,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingConfiguration,
CalculatedFieldOutput,
CalculatedFieldType,
getCalculatedFieldCurrentEntityFilter,
OutputType
} from '@shared/models/calculated-field.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models';
import { EntityId } from '@shared/models/id/entity-id';
@Component({
selector: 'tb-geofencing-configuration',
templateUrl: './geofencing-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => GeofencingConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => GeofencingConfigurationComponent),
multi: true
}
],
})
export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnInit {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF;
readonly DataKeyType = DataKeyType;
geofencingConfiguration = this.fb.group({
entityCoordinates: this.fb.group({
latitudeKeyName: [null, [Validators.required]],
longitudeKeyName: [null, [Validators.required]],
}),
zoneGroups: this.fb.control<Record<string, CalculatedFieldGeofencing>>({}),
scheduledUpdateEnabled: [true],
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
output: this.fb.control<CalculatedFieldOutput>({scope: AttributeScope.SERVER_SCOPE, type: OutputType.Timeseries})
});
currentEntityFilter: EntityFilter;
isRelatedEntity: boolean;
private propagateChange: (config: CalculatedFieldGeofencingConfiguration) => void = () => { };
constructor(private fb: FormBuilder,
private store: Store<AppState>) {
this.geofencingConfiguration.get('zoneGroups').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((zoneGroups: Record<string, CalculatedFieldGeofencing>) =>
this.checkRelatedEntity(zoneGroups)
);
this.geofencingConfiguration.get('scheduledUpdateEnabled').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value: boolean) =>
this.checkScheduledUpdateEnabled(value)
);
this.geofencingConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe(() => {
this.updatedModel(this.geofencingConfiguration.getRawValue() as any);
})
}
ngOnInit() {
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
}
validate(): ValidationErrors | null {
return this.geofencingConfiguration.valid || this.geofencingConfiguration.status === "DISABLED" ? null : { geofencingConfigError: false };
}
writeValue(config: CalculatedFieldGeofencingConfiguration): void {
this.geofencingConfiguration.patchValue(config, {emitEvent: false});
this.checkRelatedEntity(this.geofencingConfiguration.get('zoneGroups').value);
this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value);
}
registerOnChange(fn: (config: CalculatedFieldGeofencingConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.geofencingConfiguration.disable({emitEvent: false});
} else {
this.geofencingConfiguration.enable({emitEvent: false});
this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value);
}
}
private updatedModel(value: CalculatedFieldGeofencingConfiguration) {
value.type = CalculatedFieldType.GEOFENCING;
this.propagateChange(value)
}
private checkScheduledUpdateEnabled(value: boolean) {
if (value) {
this.geofencingConfiguration.get('scheduledUpdateInterval').enable({emitEvent: false});
} else {
this.geofencingConfiguration.get('scheduledUpdateInterval').disable({emitEvent: false});
}
}
private checkRelatedEntity(zoneGroups: Record<string, CalculatedFieldGeofencing>) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}
}

52
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts

@ -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.
///
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CalculatedFieldGeofencingZoneGroupsTableComponent
} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component';
import { SharedModule } from '@shared/shared.module';
import {
GeofencingConfigurationComponent
} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule
],
declarations: [
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
GeofencingConfigurationComponent
],
exports: [
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
GeofencingConfigurationComponent
]
})
export class GeofencingConfigurationModule {
}

86
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html

@ -0,0 +1,86 @@
<!--
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" [formGroup]="outputForm">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex items-center gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
<mat-select formControlName="type">
@for (type of outputTypes; track type) {
<mat-option [value]="type">{{ OutputTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (outputForm.get('type').value === OutputType.Attribute
&& (entityId.entityType === EntityType.DEVICE || entityId.entityType === EntityType.DEVICE_PROFILE)) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.attribute-scope' | translate }}</mat-label>
<mat-select formControlName="scope" class="w-full">
<mat-option [value]="AttributeScope.SERVER_SCOPE">
{{ 'calculated-fields.server-attributes' | translate }}
</mat-option>
<mat-option [value]="AttributeScope.SHARED_SCOPE">
{{ 'calculated-fields.shared-attributes' | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
</div>
@if (simpleMode) {
<div class="flex items-start gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{
(outputForm.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate
}}
</mat-label>
<input matInput formControlName="name" required>
@if (outputForm.get('name').errors && outputForm.get('name').touched) {
<mat-error>
@if (outputForm.get('name').hasError('required')) {
{{ 'common.hint.key-required' | translate }}
} @else if (outputForm.get('name').hasError('pattern')) {
{{ 'common.hint.key-pattern' | translate }}
} @else if (outputForm.get('name').hasError('maxlength')) {
{{ 'common.hint.key-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
<input matInput type="number" formControlName="decimalsByDefault">
@if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
</div>
<ng-content select=".simpleMode"></ng-content>
<!-- <div class="tb-form-row" [formGroup]="configFormGroup"-->
<!-- *ngIf="outputFormGroup.get('type').value === OutputType.Timeseries">-->
<!-- <mat-slide-toggle class="mat-slide" formControlName="useLatestTs">-->
<!-- <div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}" translate>-->
<!-- calculated-fields.use-latest-timestamp-->
<!-- </div>-->
<!-- </mat-slide-toggle>-->
<!-- </div>-->
}
</div>

148
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts

@ -0,0 +1,148 @@
///
/// 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, inject, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import {
CalculatedFieldOutput,
CalculatedFieldSimpleOutput,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models';
import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
@Component({
selector: 'tb-calculate-field-output',
templateUrl: './calculated-field-output.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldOutputComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CalculatedFieldOutputComponent),
multi: true
}
],
})
export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges {
@Input()
simpleMode = false;
@Input({required: true})
entityId: EntityId;
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly EntityType = EntityType;
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
outputForm = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
scope: [{value: AttributeScope.SERVER_SCOPE, disabled: true}],
type: [OutputType.Timeseries],
decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]],
});
private propagateChange: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void = () => { };
ngOnInit() {
this.outputForm.get('type').valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(type => this.toggleScopeByOutputType(type));
this.updatedFormWithMode();
this.outputForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => {
this.updatedModel(value)
})
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (change.currentValue !== change.previousValue) {
if (propName === 'simpleMode') {
this.updatedFormWithMode();
if (!change.firstChange) {
this.outputForm.updateValueAndValidity();
}
}
}
}
}
validate(): ValidationErrors | null {
return this.outputForm.valid ? null : {outputConfig: false};
}
writeValue(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput): void {
this.outputForm.patchValue(value, {emitEvent: false});
this.outputForm.get('type').updateValueAndValidity({onlySelf: true});
}
registerOnChange(fn: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
private updatedModel(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) {
if (this.simpleMode && 'name' in value) {
value.name = value.name?.trim() ?? '';
}
this.propagateChange(value);
}
private toggleScopeByOutputType(type: OutputType): void {
if (type === OutputType.Attribute) {
this.outputForm.get('scope').enable({emitEvent: false});
} else {
this.outputForm.get('scope').disable({emitEvent: false});
}
}
private updatedFormWithMode(): void {
if (this.simpleMode) {
this.outputForm.get('name').enable({emitEvent: false});
this.outputForm.get('decimalsByDefault').enable({emitEvent: false});
} else {
this.outputForm.get('name').disable({emitEvent: false});
this.outputForm.get('decimalsByDefault').disable({emitEvent: false});
}
}
}

36
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts

@ -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.
///
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import {
CalculatedFieldOutputComponent
} from '@home/components/calculated-fields/components/output/calculated-field-output.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
],
declarations: [
CalculatedFieldOutputComponent
],
exports: [
CalculatedFieldOutputComponent
]
})
export class CalculatedFieldOutputModule { }

99
ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html

@ -0,0 +1,99 @@
<!--
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]="propagateConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.hint.propagation-path-related-entities' | translate }}">
{{ 'calculated-fields.propagation-path-related-entities' | translate }}
</div>
<div class="flex gap-3 xs:flex-col">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic" hideRequiredMarker>
<mat-label>{{ 'calculated-fields.direction' | translate }}</mat-label>
<mat-select formControlName="direction">
@for (direction of Directions; track direction) {
<mat-option [value]="direction">{{ PropagationDirectionTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
class="flex-1"
panelWidth=""
additionalClass=""
required
[label]="'calculated-fields.relation-type' | translate"
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
</div>
<div class="tb-form-panel">
<div class="flex flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.data-propagate' | translate }}">
{{ 'calculated-fields.data-propagate' | translate }}
</div>
<tb-toggle-select formControlName="applyExpressionToResolvedArguments">
<tb-toggle-option [value]="false">{{ 'calculated-fields.propagate-type.arguments-only' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'calculated-fields.propagate-type.expression-result' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-propagate-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"
[isScript]="this.propagateConfiguration.get('applyExpressionToResolvedArguments').value"/>
</div>
<div class="tb-form-panel no-gap" [class.!hidden]="!this.propagateConfiguration.get('applyExpressionToResolvedArguments').value">
<div class="tb-form-panel-title tb-required">
{{ 'calculated-fields.expression' | translate }}
</div>
<div>
<tb-js-func required
formControlName="expression"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-expression-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="propagateConfiguration.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="propagateConfiguration.get('arguments').invalid">
{{ 'calculated-fields.test-expression-function' | translate }}
</button>
</div>
</div>
</div>
<tb-calculate-field-output formControlName="output" [entityId]="entityId">
</tb-calculate-field-output>
</div>

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

@ -0,0 +1,174 @@
///
/// 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, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { EntityId } from '@shared/models/id/entity-id';
import { Observable, of } from 'rxjs';
import {
calculatedFieldDefaultScript,
CalculatedFieldOutput,
CalculatedFieldPropagationConfiguration,
CalculatedFieldType,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
OutputType,
PropagationDirectionTranslations,
PropagationWithExpression
} from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@app/shared/models/rule-node.models';
import { EntitySearchDirection } from '@shared/models/relation.models';
@Component({
selector: 'tb-propagation-configuration',
templateUrl: './propagation-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PropagationConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => PropagationConfigurationComponent),
multi: true
}
],
})
export class PropagationConfigurationComponent implements ControlValueAccessor, Validator {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
@Input({required: true})
testScript: () => Observable<string>;
propagateConfiguration = this.fb.group({
arguments: this.fb.control({}),
applyExpressionToResolvedArguments: [false],
direction: [EntitySearchDirection.TO, Validators.required],
relationType: ['Contains', Validators.required],
expression: [calculatedFieldDefaultScript],
output: this.fb.control<CalculatedFieldOutput>({
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries,
}),
});
readonly ScriptLanguage = ScriptLanguage;
readonly CalculatedFieldType = CalculatedFieldType;
readonly OutputType = OutputType;
readonly Directions = Object.values(EntitySearchDirection) as Array<EntitySearchDirection>;
readonly PropagationDirectionTranslations = PropagationDirectionTranslations;
functionArgs$ = this.propagateConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.propagateConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {}))
);
argumentsHighlightRules$ = this.propagateConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
private propagateChange: (config: CalculatedFieldPropagationConfiguration) => void = () => { };
constructor(private fb: FormBuilder) {
this.propagateConfiguration.get('applyExpressionToResolvedArguments').valueChanges.pipe(
takeUntilDestroyed()
).subscribe(() => {
this.updatedFormWithScript();
})
this.propagateConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: CalculatedFieldPropagationConfiguration) => {
this.updatedModel(value);
})
}
validate(): ValidationErrors | null {
return this.propagateConfiguration.valid || this.propagateConfiguration.status === "DISABLED" ? null : {invalidPropagateConfig: false};
}
writeValue(value: PropagationWithExpression): void {
value.expression = value.expression ?? calculatedFieldDefaultScript;
this.propagateConfiguration.patchValue(value, {emitEvent: false});
this.updatedFormWithScript();
setTimeout(() => {
this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: CalculatedFieldPropagationConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.propagateConfiguration.disable({emitEvent: false});
} else {
this.propagateConfiguration.enable({emitEvent: false});
this.updatedFormWithScript();
}
}
onTestScript() {
this.testScript().subscribe((expression) => {
this.propagateConfiguration.get('expression').setValue(expression);
this.propagateConfiguration.get('expression').markAsDirty();
})
}
fetchOptions(searchText: string): Observable<Array<string>> {
const search = searchText ? searchText?.toLowerCase() : '';
return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search))));
}
private updatedModel(value: CalculatedFieldPropagationConfiguration): void {
value.type = CalculatedFieldType.PROPAGATION;
this.propagateChange(value);
}
private updatedFormWithScript() {
if (this.propagateConfiguration.get('applyExpressionToResolvedArguments').value) {
this.propagateConfiguration.get('expression').enable({emitEvent: false});
} else {
this.propagateConfiguration.get('expression').disable({emitEvent: false});
}
}
}

44
ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts

@ -0,0 +1,44 @@
///
/// 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 {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
import {
CalculatedFieldArgumentsTableModule
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module';
import {
PropagationConfigurationComponent
} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
],
declarations: [
PropagationConfigurationComponent,
],
exports: [
PropagationConfigurationComponent,
]
})
export class PropagationConfigurationModule { }

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts

@ -15,7 +15,5 @@
///
export * from './dialog/calculated-field-dialog.component';
export * from './arguments-table/calculated-field-arguments-table.component';
export * from './panel/calculated-field-argument-panel.component';
export * from './debug-dialog/calculated-field-debug-dialog.component';
export * from './test-dialog/calculated-field-script-test-dialog.component';

98
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.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.
-->
<div [formGroup]="simpleConfiguration" class="tb-form-panel no-border no-padding">
<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]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"
[isScript]="isScript" />
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (isScript ? 'calculated-fields.type.script' : 'calculated-fields.expression') | translate }}
</div>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="isScript">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'"
required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
@if (simpleConfiguration.get('expressionSIMPLE').errors && simpleConfiguration.get('expressionSIMPLE').touched) {
<mat-error>
@if (simpleConfiguration.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (simpleConfiguration.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (simpleConfiguration.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="!isScript">
<tb-js-func required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="simpleConfiguration.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="simpleConfiguration.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
</div>
</div>
<tb-calculate-field-output formControlName="output" [simpleMode]="!isScript" [entityId]="entityId">
<div class="tb-form-row simpleMode"
[class.!hidden]="simpleConfiguration.get('output').value.type !== OutputType.Timeseries">
<mat-slide-toggle class="mat-slide" formControlName="useLatestTs">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}" translate>
calculated-fields.use-latest-timestamp
</div>
</mat-slide-toggle>
</div>
</tb-calculate-field-output>
</div>

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

@ -0,0 +1,206 @@
///
/// 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, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import {
calculatedFieldDefaultScript,
CalculatedFieldScriptConfiguration,
CalculatedFieldSimpleConfiguration,
CalculatedFieldSimpleOutput,
CalculatedFieldType,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
OutputType
} from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { deepClone } from '@core/utils';
import { EntityId } from '@shared/models/id/entity-id';
import { Observable } from 'rxjs';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { map } from 'rxjs/operators';
type SimpeConfiguration = CalculatedFieldSimpleConfiguration | CalculatedFieldScriptConfiguration;
@Component({
selector: 'tb-simple-configuration',
templateUrl: './simple-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SimpleConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => SimpleConfigurationComponent),
multi: true
}
],
})
export class SimpleConfigurationComponent implements ControlValueAccessor, Validator, OnChanges {
@Input()
isScript: boolean;
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
@Input({required: true})
testScript: () => Observable<string>;
simpleConfiguration = this.fb.group({
arguments: this.fb.control({}),
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.control<CalculatedFieldSimpleOutput>({
name: '',
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries,
decimalsByDefault: null
}),
useLatestTs: [false]
});
readonly ScriptLanguage = ScriptLanguage;
readonly OutputType = OutputType;
functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {}))
);
argumentsHighlightRules$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
private propagateChange: (config: SimpeConfiguration) => void = () => { };
constructor(private fb: FormBuilder) {
this.simpleConfiguration.get('output').valueChanges.pipe(
takeUntilDestroyed(),
).subscribe(() => {
this.toggleScopeByOutputType();
});
this.simpleConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value) => {
const { expressionSIMPLE, expressionSCRIPT, ...config } = value;
const cfConfig = config as SimpeConfiguration;
cfConfig.expression = this.isScript ? expressionSCRIPT : expressionSIMPLE;
this.updatedModel(cfConfig);
})
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (change.currentValue !== change.previousValue) {
if (propName === 'isScript') {
this.updatedFormWithScript();
if (!change.firstChange) {
this.simpleConfiguration.updateValueAndValidity();
}
}
}
}
}
validate(): ValidationErrors | null {
return this.simpleConfiguration.valid || this.simpleConfiguration.status === "DISABLED" ? null : {invalidSimpleConfig: false};
}
writeValue(value: SimpeConfiguration): void {
const formValue: any = deepClone(value);
if (this.isScript) {
formValue.expressionSCRIPT = formValue.expression ?? calculatedFieldDefaultScript;
} else {
formValue.expressionSIMPLE = formValue.expression;
}
this.simpleConfiguration.patchValue(formValue, {emitEvent: false});
this.updatedFormWithScript();
setTimeout(() => {
this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: SimpeConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void {
}
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.simpleConfiguration.disable({emitEvent: false});
} else {
this.simpleConfiguration.enable({emitEvent: false});
this.updatedFormWithScript();
}
}
onTestScript() {
this.testScript().subscribe((expression) => {
this.simpleConfiguration.get('expressionSCRIPT').setValue(expression);
this.simpleConfiguration.get('expressionSCRIPT').markAsDirty();
})
}
private updatedModel(value: SimpeConfiguration): void {
value.type = this.isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE;
this.propagateChange(value);
}
private updatedFormWithScript() {
if (this.isScript) {
this.simpleConfiguration.get('expressionSIMPLE').disable({emitEvent: false});
this.simpleConfiguration.get('expressionSCRIPT').enable({emitEvent: false});
} else {
this.simpleConfiguration.get('expressionSIMPLE').enable({emitEvent: false});
this.simpleConfiguration.get('expressionSCRIPT').disable({emitEvent: false});
}
this.toggleScopeByOutputType();
}
private toggleScopeByOutputType(): void {
if (this.isScript || this.simpleConfiguration.get('output').value.type === OutputType.Attribute) {
this.simpleConfiguration.get('useLatestTs').disable({emitEvent: false});
} else {
this.simpleConfiguration.get('useLatestTs').enable({emitEvent: false});
}
}
}

44
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts

@ -0,0 +1,44 @@
///
/// 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 {
SimpleConfigurationComponent
} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
import {
CalculatedFieldArgumentsTableModule
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
],
declarations: [
SimpleConfigurationComponent,
],
exports: [
SimpleConfigurationComponent
]
})
export class SimpleConfigurationModule {}

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

@ -183,36 +183,18 @@ import {
} from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component';
import { EntityChipsComponent } from '@home/components/entity/entity-chips.component';
import { DashboardViewComponent } from '@home/components/dashboard-view/dashboard-view.component';
import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component';
import { CalculatedFieldDialogComponent } from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component';
import {
EntityDebugSettingsButtonComponent
} from '@home/components/entity/debug/entity-debug-settings-button.component';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component';
import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-argument-panel.component';
import {
CalculatedFieldDebugDialogComponent
} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component';
import {
CalculatedFieldScriptTestDialogComponent
} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component';
import {
CalculatedFieldTestArgumentsComponent
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component";
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component';
import {
CalculatedFieldGeofencingZoneGroupsTableComponent
} from '@home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component';
CalculatedFieldDebugDialogComponent
} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component';
import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module';
@NgModule({
declarations:
@ -225,6 +207,8 @@ import {
EntityDetailsPageComponent,
AuditLogTableComponent,
AuditLogDetailsDialogComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDebugDialogComponent,
EventContentDialogComponent,
EventTableHeaderComponent,
EventTableComponent,
@ -357,15 +341,6 @@ import {
SendNotificationButtonComponent,
EntityChipsComponent,
DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
@ -375,6 +350,7 @@ import {
CommonModule,
SharedModule,
SharedHomeComponentsModule,
CalculatedFieldsModule,
WidgetConfigComponentsModule,
BasicWidgetConfigModule,
Lwm2mProfileComponentsModule,
@ -392,6 +368,7 @@ import {
EntityDetailsPanelComponent,
EntityDetailsPageComponent,
AuditLogTableComponent,
CalculatedFieldsTableComponent,
EventTableComponent,
EdgeDownlinkTableHeaderComponent,
EdgeDownlinkTableComponent,
@ -508,15 +485,6 @@ import {
SendNotificationButtonComponent,
EntityChipsComponent,
DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,

216
ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<section [formGroup]="defaultTenantProfileConfigurationFormGroup" class="flex flex-col">
<section [formGroup]="tenantProfileConfigurationForm" class="flex flex-col">
<fieldset class="fields-group">
<legend class="group-title">
{{ 'tenant-profile.entities' | translate }} <span translate>tenant-profile.unlimited</span>
@ -26,10 +26,10 @@
<input matInput required min="0" step="1"
formControlName="maxDevices"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDevices').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDevices').hasError('required')">
{{ 'tenant-profile.maximum-devices-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDevices').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDevices').hasError('min')">
{{ 'tenant-profile.maximum-devices-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -39,10 +39,10 @@
<input matInput required min="0" step="1"
formControlName="maxDashboards"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDashboards').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDashboards').hasError('required')">
{{ 'tenant-profile.maximum-dashboards-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDashboards').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDashboards').hasError('min')">
{{ 'tenant-profile.maximum-dashboards-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -54,10 +54,10 @@
<input matInput required min="0" step="1"
formControlName="maxAssets"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxAssets').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxAssets').hasError('required')">
{{ 'tenant-profile.maximum-assets-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxAssets').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxAssets').hasError('min')">
{{ 'tenant-profile.maximum-assets-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -67,10 +67,10 @@
<input matInput required min="0" step="1"
formControlName="maxUsers"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxUsers').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxUsers').hasError('required')">
{{ 'tenant-profile.maximum-users-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxUsers').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxUsers').hasError('min')">
{{ 'tenant-profile.maximum-users-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -89,10 +89,10 @@
<input matInput required min="0" step="1"
formControlName="maxCustomers"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxCustomers').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxCustomers').hasError('required')">
{{ 'tenant-profile.maximum-customers-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxCustomers').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxCustomers').hasError('min')">
{{ 'tenant-profile.maximum-customers-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -102,10 +102,10 @@
<input matInput required min="0" step="1"
formControlName="maxRuleChains"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRuleChains').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRuleChains').hasError('required')">
{{ 'tenant-profile.maximum-rule-chains-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRuleChains').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRuleChains').hasError('min')">
{{ 'tenant-profile.maximum-rule-chains-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -117,10 +117,10 @@
<input matInput required min="0" step="1"
formControlName="maxEdges"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxEdges').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxEdges').hasError('required')">
{{ 'tenant-profile.maximum-edges-required' | translate }}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxEdges').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxEdges').hasError('min')">
{{ 'tenant-profile.maximum-edges-range' | translate }}
</mat-error>
<mat-hint></mat-hint>
@ -141,10 +141,10 @@
<input matInput required min="0" step="1"
formControlName="maxREExecutions"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxREExecutions').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxREExecutions').hasError('required')">
{{ 'tenant-profile.max-r-e-executions-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxREExecutions').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxREExecutions').hasError('min')">
{{ 'tenant-profile.max-r-e-executions-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -154,10 +154,10 @@
<input matInput required min="0" step="1"
formControlName="maxTransportMessages"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxTransportMessages').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxTransportMessages').hasError('required')">
{{ 'tenant-profile.max-transport-messages-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxTransportMessages').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxTransportMessages').hasError('min')">
{{ 'tenant-profile.max-transport-messages-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -176,10 +176,10 @@
<input matInput required min="0" step="1"
formControlName="maxJSExecutions"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxJSExecutions').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxJSExecutions').hasError('required')">
{{ 'tenant-profile.max-j-s-executions-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxJSExecutions').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxJSExecutions').hasError('min')">
{{ 'tenant-profile.max-j-s-executions-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -189,10 +189,10 @@
<input matInput required min="0" step="1"
formControlName="maxTbelExecutions"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxTbelExecutions').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxTbelExecutions').hasError('required')">
{{ 'tenant-profile.max-tbel-executions-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxTbelExecutions').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxTbelExecutions').hasError('min')">
{{ 'tenant-profile.max-tbel-executions-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -204,10 +204,10 @@
<input matInput required min="0" step="1"
formControlName="maxRuleNodeExecutionsPerMessage"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRuleNodeExecutionsPerMessage').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRuleNodeExecutionsPerMessage').hasError('required')">
{{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRuleNodeExecutionsPerMessage').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRuleNodeExecutionsPerMessage').hasError('min')">
{{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -217,10 +217,10 @@
<input matInput required min="0" step="1"
formControlName="maxTransportDataPoints"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxTransportDataPoints').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxTransportDataPoints').hasError('required')">
{{ 'tenant-profile.max-transport-data-points-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxTransportDataPoints').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxTransportDataPoints').hasError('min')">
{{ 'tenant-profile.max-transport-data-points-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -239,10 +239,10 @@
<input matInput required min="0" step="1"
formControlName="maxCalculatedFieldsPerEntity"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxCalculatedFieldsPerEntity').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxCalculatedFieldsPerEntity').hasError('required')">
{{ 'tenant-profile.max-calculated-fields-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxCalculatedFieldsPerEntity').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxCalculatedFieldsPerEntity').hasError('min')">
{{ 'tenant-profile.max-calculated-fields-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -252,10 +252,10 @@
<input matInput required min="0" step="1"
formControlName="maxDataPointsPerRollingArg"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDataPointsPerRollingArg').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDataPointsPerRollingArg').hasError('required')">
{{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDataPointsPerRollingArg').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDataPointsPerRollingArg').hasError('min')">
{{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -267,42 +267,14 @@
<input matInput required min="0" step="1"
formControlName="maxArgumentsPerCF"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxArgumentsPerCF').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxArgumentsPerCF').hasError('required')">
{{ 'tenant-profile.max-arguments-per-cf-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxArgumentsPerCF').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxArgumentsPerCF').hasError('min')">
{{ 'tenant-profile.max-arguments-per-cf-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.max-related-level-per-argument</mat-label>
<input matInput required min="1" step="1"
formControlName="maxRelationLevelPerCfArgument"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRelationLevelPerCfArgument').hasError('required')">
{{ 'tenant-profile.max-related-level-per-argument-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRelationLevelPerCfArgument').hasError('min')">
{{ 'tenant-profile.max-related-level-per-argument-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4">
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.min-allowed-scheduled-update-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="minAllowedScheduledUpdateIntervalInSecForCF"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('required')">
{{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('min')">
{{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<div class="flex-1"></div>
</div>
<mat-expansion-panel class="configuration-panel">
@ -318,10 +290,10 @@
<input matInput required min="0" step="1"
formControlName="maxStateSizeInKBytes"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxStateSizeInKBytes').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxStateSizeInKBytes').hasError('required')">
{{ 'tenant-profile.max-state-size-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxStateSizeInKBytes').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxStateSizeInKBytes').hasError('min')">
{{ 'tenant-profile.max-state-size-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -331,15 +303,59 @@
<input matInput required min="0" step="1"
formControlName="maxSingleValueArgumentSizeInKBytes"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxSingleValueArgumentSizeInKBytes').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxSingleValueArgumentSizeInKBytes').hasError('required')">
{{ 'tenant-profile.max-value-argument-size-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxSingleValueArgumentSizeInKBytes').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxSingleValueArgumentSizeInKBytes').hasError('min')">
{{ 'tenant-profile.max-value-argument-size-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4">
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.max-related-level-per-argument</mat-label>
<input matInput required min="1" step="1"
formControlName="maxRelationLevelPerCfArgument"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRelationLevelPerCfArgument').hasError('required')">
{{ 'tenant-profile.max-related-level-per-argument-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRelationLevelPerCfArgument').hasError('min')">
{{ 'tenant-profile.max-related-level-per-argument-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.min-allowed-scheduled-update-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="minAllowedScheduledUpdateIntervalInSecForCF"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('required')">
{{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('min')">
{{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4">
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.relation-search-entity-limit</mat-label>
<input matInput required min="1" step="1"
formControlName="maxRelatedEntitiesToReturnPerCfArgument"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('required')">
{{ 'tenant-profile.relation-search-entity-limit-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('min')">
{{ 'tenant-profile.relation-search-entity-limit-range' | translate}}
</mat-error>
<mat-hint translate>tenant-profile.relation-search-entity-limit-hint</mat-hint>
</mat-form-field>
<div class="flex-1"></div>
</div>
</ng-template>
</mat-expansion-panel>
</fieldset>
@ -354,10 +370,10 @@
<input matInput required min="0" step="1"
formControlName="maxDPStorageDays"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDPStorageDays').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDPStorageDays').hasError('required')">
{{ 'tenant-profile.max-d-p-storage-days-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDPStorageDays').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDPStorageDays').hasError('min')">
{{ 'tenant-profile.max-d-p-storage-days-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -367,10 +383,10 @@
<input matInput required min="0" step="1"
formControlName="alarmsTtlDays"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('alarmsTtlDays').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('alarmsTtlDays').hasError('required')">
{{ 'tenant-profile.alarms-ttl-days-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('alarmsTtlDays').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('alarmsTtlDays').hasError('min')">
{{ 'tenant-profile.alarms-ttl-days-days-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -382,10 +398,10 @@
<input matInput required min="0" step="1"
formControlName="defaultStorageTtlDays"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('defaultStorageTtlDays').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('defaultStorageTtlDays').hasError('required')">
{{ 'tenant-profile.default-storage-ttl-days-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('defaultStorageTtlDays').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('defaultStorageTtlDays').hasError('min')">
{{ 'tenant-profile.default-storage-ttl-days-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -395,10 +411,10 @@
<input matInput required min="0" step="1"
formControlName="rpcTtlDays"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('rpcTtlDays').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('rpcTtlDays').hasError('required')">
{{ 'tenant-profile.rpc-ttl-days-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('rpcTtlDays').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('rpcTtlDays').hasError('min')">
{{ 'tenant-profile.rpc-ttl-days-days-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -410,10 +426,10 @@
<input matInput required min="0" step="1"
formControlName="queueStatsTtlDays"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('queueStatsTtlDays').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('queueStatsTtlDays').hasError('required')">
{{ 'tenant-profile.queue-stats-ttl-days-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('queueStatsTtlDays').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('queueStatsTtlDays').hasError('min')">
{{ 'tenant-profile.queue-stats-ttl-days-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -423,10 +439,10 @@
<input matInput required min="0" step="1"
formControlName="ruleEngineExceptionsTtlDays"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('ruleEngineExceptionsTtlDays').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('ruleEngineExceptionsTtlDays').hasError('required')">
{{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('ruleEngineExceptionsTtlDays').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('ruleEngineExceptionsTtlDays').hasError('min')">
{{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -441,16 +457,16 @@
<mat-slide-toggle class="slide-toggle-element flex-1" formControlName="smsEnabled">
{{ 'tenant-profile.sms-enabled' | translate }}
</mat-slide-toggle>
<mat-form-field *ngIf="defaultTenantProfileConfigurationFormGroup.get('smsEnabled').value" class="mat-block flex-1"
<mat-form-field *ngIf="tenantProfileConfigurationForm.get('smsEnabled').value" class="mat-block flex-1"
appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.max-sms</mat-label>
<input matInput required min="0" step="1"
formControlName="maxSms"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxSms').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxSms').hasError('required')">
{{ 'tenant-profile.max-sms-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxSms').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxSms').hasError('min')">
{{ 'tenant-profile.max-sms-range' | translate}}
</mat-error>
</mat-form-field>
@ -460,10 +476,10 @@
<input matInput required min="0" step="1"
formControlName="maxEmails"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxEmails').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxEmails').hasError('required')">
{{ 'tenant-profile.max-emails-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxEmails').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxEmails').hasError('min')">
{{ 'tenant-profile.max-emails-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -473,10 +489,10 @@
<input matInput required min="0" step="1"
formControlName="maxCreatedAlarms"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxCreatedAlarms').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxCreatedAlarms').hasError('required')">
{{ 'tenant-profile.max-created-alarms-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxCreatedAlarms').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxCreatedAlarms').hasError('min')">
{{ 'tenant-profile.max-created-alarms-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -494,7 +510,7 @@
<input matInput min="0" step="1"
formControlName="maxDebugModeDurationMinutes"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxDebugModeDurationMinutes').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxDebugModeDurationMinutes').hasError('min')">
{{ 'tenant-profile.maximum-debug-duration-min-range' | translate }}
</mat-error>
<mat-hint></mat-hint>
@ -513,10 +529,10 @@
<input matInput required min="0" step="1"
formControlName="maxResourcesInBytes"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxResourcesInBytes').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxResourcesInBytes').hasError('required')">
{{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxResourcesInBytes').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxResourcesInBytes').hasError('min')">
{{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -526,10 +542,10 @@
<input matInput required min="0" step="1"
formControlName="maxOtaPackagesInBytes"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxOtaPackagesInBytes').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxOtaPackagesInBytes').hasError('required')">
{{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxOtaPackagesInBytes').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxOtaPackagesInBytes').hasError('min')">
{{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -541,10 +557,10 @@
<input matInput required min="0" step="1"
formControlName="maxResourceSize"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxResourceSize').hasError('required')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxResourceSize').hasError('required')">
{{ 'tenant-profile.maximum-resource-size-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxResourceSize').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxResourceSize').hasError('min')">
{{ 'tenant-profile.maximum-resource-size-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
@ -561,14 +577,14 @@
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-sessions-per-tenant</mat-label>
<input matInput type="number" formControlName="maxWsSessionsPerTenant">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSessionsPerTenant').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSessionsPerTenant').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-subscriptions-per-tenant</mat-label>
<input matInput type="number" formControlName="maxWsSubscriptionsPerTenant">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSubscriptionsPerTenant').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSubscriptionsPerTenant').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
@ -577,14 +593,14 @@
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-sessions-per-customer</mat-label>
<input matInput type="number" formControlName="maxWsSessionsPerCustomer">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSessionsPerCustomer').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSessionsPerCustomer').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-subscriptions-per-customer</mat-label>
<input matInput type="number" formControlName="maxWsSubscriptionsPerCustomer">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSubscriptionsPerCustomer').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSubscriptionsPerCustomer').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
@ -600,14 +616,14 @@
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-sessions-per-public-user</mat-label>
<input matInput type="number" formControlName="maxWsSessionsPerPublicUser">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSessionsPerPublicUser').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSessionsPerPublicUser').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-subscriptions-per-public-user</mat-label>
<input matInput type="number" formControlName="maxWsSubscriptionsPerPublicUser">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSubscriptionsPerPublicUser').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSubscriptionsPerPublicUser').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
@ -616,14 +632,14 @@
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-sessions-per-regular-user</mat-label>
<input matInput type="number" formControlName="maxWsSessionsPerRegularUser">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSessionsPerRegularUser').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSessionsPerRegularUser').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-max-subscriptions-per-regular-user</mat-label>
<input matInput type="number" formControlName="maxWsSubscriptionsPerRegularUser">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxWsSubscriptionsPerRegularUser').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxWsSubscriptionsPerRegularUser').hasError('min')">
{{ 'tenant-profile.too-small-value-zero' | translate}}
</mat-error>
</mat-form-field>
@ -632,7 +648,7 @@
<mat-form-field class="mat-block flex-1" appearance="fill">
<mat-label translate>tenant-profile.ws-limit-queue-per-session</mat-label>
<input matInput type="number" formControlName="wsMsgQueueLimitPerSession">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('wsMsgQueueLimitPerSession').hasError('min')">
<mat-error *ngIf="tenantProfileConfigurationForm.get('wsMsgQueueLimitPerSession').hasError('min')">
{{ 'tenant-profile.too-small-value-one' | translate}}
</mat-error>
<mat-hint>

216
ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts

@ -14,16 +14,13 @@
/// limitations under the License.
///
import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core';
import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DefaultTenantProfileConfiguration, TenantProfileConfiguration } from '@shared/models/tenant.model';
import { Component, forwardRef, Input } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { DefaultTenantProfileConfiguration, FormControlsFrom } from '@shared/models/tenant.model';
import { isDefinedAndNotNull } from '@core/utils';
import { RateLimitsType } from './rate-limits/rate-limits.models';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-default-tenant-profile-configuration',
@ -35,112 +32,107 @@ import { Subject } from 'rxjs';
multi: true
}]
})
export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy {
export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor {
defaultTenantProfileConfigurationFormGroup: UntypedFormGroup;
tenantProfileConfigurationForm: FormGroup<FormControlsFrom<DefaultTenantProfileConfiguration>>;
private requiredValue: boolean;
private destroy$ = new Subject<void>();
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
@coerceBoolean()
required: boolean;
@Input()
@coerceBoolean()
disabled: boolean;
rateLimitsType = RateLimitsType;
private propagateChange = (v: any) => { };
constructor(private store: Store<AppState>,
private fb: UntypedFormBuilder) {
this.defaultTenantProfileConfigurationFormGroup = this.fb.group({
maxDevices: [null, [Validators.required, Validators.min(0)]],
maxAssets: [null, [Validators.required, Validators.min(0)]],
maxCustomers: [null, [Validators.required, Validators.min(0)]],
maxUsers: [null, [Validators.required, Validators.min(0)]],
maxDashboards: [null, [Validators.required, Validators.min(0)]],
maxRuleChains: [null, [Validators.required, Validators.min(0)]],
maxEdges: [null, [Validators.required, Validators.min(0)]],
maxResourcesInBytes: [null, [Validators.required, Validators.min(0)]],
maxOtaPackagesInBytes: [null, [Validators.required, Validators.min(0)]],
maxResourceSize: [null, [Validators.required, Validators.min(0)]],
transportTenantMsgRateLimit: [null, []],
transportTenantTelemetryMsgRateLimit: [null, []],
transportTenantTelemetryDataPointsRateLimit: [null, []],
transportDeviceMsgRateLimit: [null, []],
transportDeviceTelemetryMsgRateLimit: [null, []],
transportDeviceTelemetryDataPointsRateLimit: [null, []],
transportGatewayMsgRateLimit: [null, []],
transportGatewayTelemetryMsgRateLimit: [null, []],
transportGatewayTelemetryDataPointsRateLimit: [null, []],
transportGatewayDeviceMsgRateLimit: [null, []],
transportGatewayDeviceTelemetryMsgRateLimit: [null, []],
transportGatewayDeviceTelemetryDataPointsRateLimit: [null, []],
tenantEntityExportRateLimit: [null, []],
tenantEntityImportRateLimit: [null, []],
tenantNotificationRequestsRateLimit: [null, []],
tenantNotificationRequestsPerRuleRateLimit: [null, []],
maxTransportMessages: [null, [Validators.required, Validators.min(0)]],
maxTransportDataPoints: [null, [Validators.required, Validators.min(0)]],
maxREExecutions: [null, [Validators.required, Validators.min(0)]],
maxJSExecutions: [null, [Validators.required, Validators.min(0)]],
maxTbelExecutions: [null, [Validators.required, Validators.min(0)]],
maxDPStorageDays: [null, [Validators.required, Validators.min(0)]],
maxRuleNodeExecutionsPerMessage: [null, [Validators.required, Validators.min(0)]],
maxEmails: [null, [Validators.required, Validators.min(0)]],
maxSms: [null, []],
smsEnabled: [null, []],
maxCreatedAlarms: [null, [Validators.required, Validators.min(0)]],
maxDebugModeDurationMinutes: [null, [Validators.min(0)]],
defaultStorageTtlDays: [null, [Validators.required, Validators.min(0)]],
alarmsTtlDays: [null, [Validators.required, Validators.min(0)]],
rpcTtlDays: [null, [Validators.required, Validators.min(0)]],
queueStatsTtlDays: [null, [Validators.required, Validators.min(0)]],
ruleEngineExceptionsTtlDays: [null, [Validators.required, Validators.min(0)]],
tenantServerRestLimitsConfiguration: [null, []],
customerServerRestLimitsConfiguration: [null, []],
maxWsSessionsPerTenant: [null, [Validators.min(0)]],
maxWsSessionsPerCustomer: [null, [Validators.min(0)]],
maxWsSessionsPerRegularUser: [null, [Validators.min(0)]],
maxWsSessionsPerPublicUser: [null, [Validators.min(0)]],
wsMsgQueueLimitPerSession: [null, [Validators.min(0)]],
maxWsSubscriptionsPerTenant: [null, [Validators.min(0)]],
maxWsSubscriptionsPerCustomer: [null, [Validators.min(0)]],
maxWsSubscriptionsPerRegularUser: [null, [Validators.min(0)]],
maxWsSubscriptionsPerPublicUser: [null, [Validators.min(0)]],
wsUpdatesPerSessionRateLimit: [null, []],
cassandraWriteQueryTenantCoreRateLimits: [null, []],
cassandraReadQueryTenantCoreRateLimits: [null, []],
cassandraWriteQueryTenantRuleEngineRateLimits: [null, []],
cassandraReadQueryTenantRuleEngineRateLimits: [null, []],
edgeEventRateLimits: [null, []],
edgeEventRateLimitsPerEdge: [null, []],
edgeUplinkMessagesRateLimits: [null, []],
edgeUplinkMessagesRateLimitsPerEdge: [null, []],
maxCalculatedFieldsPerEntity: [null, [Validators.required, Validators.min(0)]],
maxArgumentsPerCF: [null, [Validators.required, Validators.min(0)]],
maxRelationLevelPerCfArgument: [null, [Validators.required, Validators.min(1)]],
minAllowedScheduledUpdateIntervalInSecForCF: [null, [Validators.required, Validators.min(0)]],
maxDataPointsPerRollingArg: [null, [Validators.required, Validators.min(0)]],
maxStateSizeInKBytes: [null, [Validators.required, Validators.min(0)]],
calculatedFieldDebugEventsRateLimit: [null, []],
maxSingleValueArgumentSizeInKBytes: [null, [Validators.required, Validators.min(0)]],
private propagateChange = (_v: any) => { };
constructor(private fb: FormBuilder) {
this.tenantProfileConfigurationForm = this.fb.group({
maxDevices: [0, [Validators.required, Validators.min(0)]],
maxAssets: [0, [Validators.required, Validators.min(0)]],
maxCustomers: [0, [Validators.required, Validators.min(0)]],
maxUsers: [0, [Validators.required, Validators.min(0)]],
maxDashboards: [0, [Validators.required, Validators.min(0)]],
maxRuleChains: [0, [Validators.required, Validators.min(0)]],
maxEdges: [0, [Validators.required, Validators.min(0)]],
maxResourcesInBytes: [0, [Validators.required, Validators.min(0)]],
maxOtaPackagesInBytes: [0, [Validators.required, Validators.min(0)]],
maxResourceSize: [0, [Validators.required, Validators.min(0)]],
transportTenantMsgRateLimit: [''],
transportTenantTelemetryMsgRateLimit: [''],
transportTenantTelemetryDataPointsRateLimit: [''],
transportDeviceMsgRateLimit: [''],
transportDeviceTelemetryMsgRateLimit: [''],
transportDeviceTelemetryDataPointsRateLimit: [''],
transportGatewayMsgRateLimit: [''],
transportGatewayTelemetryMsgRateLimit: [''],
transportGatewayTelemetryDataPointsRateLimit: [''],
transportGatewayDeviceMsgRateLimit: [''],
transportGatewayDeviceTelemetryMsgRateLimit: [''],
transportGatewayDeviceTelemetryDataPointsRateLimit: [''],
tenantEntityExportRateLimit: [''],
tenantEntityImportRateLimit: [''],
tenantNotificationRequestsRateLimit: [''],
tenantNotificationRequestsPerRuleRateLimit: [''],
maxTransportMessages: [0, [Validators.required, Validators.min(0)]],
maxTransportDataPoints: [0, [Validators.required, Validators.min(0)]],
maxREExecutions: [0, [Validators.required, Validators.min(0)]],
maxJSExecutions: [0, [Validators.required, Validators.min(0)]],
maxTbelExecutions: [0, [Validators.required, Validators.min(0)]],
maxDPStorageDays: [0, [Validators.required, Validators.min(0)]],
maxRuleNodeExecutionsPerMessage: [0, [Validators.required, Validators.min(0)]],
maxEmails: [0, [Validators.required, Validators.min(0)]],
maxSms: [0],
smsEnabled: [false],
maxCreatedAlarms: [0, [Validators.required, Validators.min(0)]],
maxDebugModeDurationMinutes: [0, [Validators.min(0)]],
defaultStorageTtlDays: [0, [Validators.required, Validators.min(0)]],
alarmsTtlDays: [0, [Validators.required, Validators.min(0)]],
rpcTtlDays: [0, [Validators.required, Validators.min(0)]],
queueStatsTtlDays: [0, [Validators.required, Validators.min(0)]],
ruleEngineExceptionsTtlDays: [0, [Validators.required, Validators.min(0)]],
tenantServerRestLimitsConfiguration: [''],
customerServerRestLimitsConfiguration: [''],
maxWsSessionsPerTenant: [0, [Validators.min(0)]],
maxWsSessionsPerCustomer: [0, [Validators.min(0)]],
maxWsSessionsPerRegularUser: [0, [Validators.min(0)]],
maxWsSessionsPerPublicUser: [0, [Validators.min(0)]],
wsMsgQueueLimitPerSession: [0, [Validators.min(0)]],
maxWsSubscriptionsPerTenant: [0, [Validators.min(0)]],
maxWsSubscriptionsPerCustomer: [0, [Validators.min(0)]],
maxWsSubscriptionsPerRegularUser: [0, [Validators.min(0)]],
maxWsSubscriptionsPerPublicUser: [0, [Validators.min(0)]],
wsUpdatesPerSessionRateLimit: [''],
cassandraWriteQueryTenantCoreRateLimits: [''],
cassandraReadQueryTenantCoreRateLimits: [''],
cassandraWriteQueryTenantRuleEngineRateLimits: [''],
cassandraReadQueryTenantRuleEngineRateLimits: [''],
edgeEventRateLimits: [''],
edgeEventRateLimitsPerEdge: [''],
edgeUplinkMessagesRateLimits: [''],
edgeUplinkMessagesRateLimitsPerEdge: [''],
maxCalculatedFieldsPerEntity: [0, [Validators.required, Validators.min(0)]],
maxArgumentsPerCF: [0, [Validators.required, Validators.min(0)]],
maxRelationLevelPerCfArgument: [1, [Validators.required, Validators.min(1)]],
maxRelatedEntitiesToReturnPerCfArgument: [1, [Validators.required, Validators.min(1)]],
minAllowedScheduledUpdateIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]],
maxDataPointsPerRollingArg: [0, [Validators.required, Validators.min(0)]],
maxStateSizeInKBytes: [0, [Validators.required, Validators.min(0)]],
calculatedFieldDebugEventsRateLimit: [''],
maxSingleValueArgumentSizeInKBytes: [0, [Validators.required, Validators.min(0)]],
});
this.defaultTenantProfileConfigurationFormGroup.get('smsEnabled').valueChanges.pipe(
takeUntil(this.destroy$)
this.tenantProfileConfigurationForm.get('smsEnabled').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: boolean) => {
this.maxSmsValidation(value);
}
);
this.defaultTenantProfileConfigurationFormGroup.valueChanges.pipe(
takeUntil(this.destroy$)
this.tenantProfileConfigurationForm.valueChanges.pipe(
takeUntilDestroyed()
).subscribe(() => {
this.updateModel();
});
@ -148,48 +140,40 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA
private maxSmsValidation(smsEnabled: boolean) {
if (smsEnabled) {
this.defaultTenantProfileConfigurationFormGroup.get('maxSms').addValidators([Validators.required, Validators.min(0)]);
this.tenantProfileConfigurationForm.get('maxSms').addValidators([Validators.required, Validators.min(0)]);
} else {
this.defaultTenantProfileConfigurationFormGroup.get('maxSms').clearValidators();
this.tenantProfileConfigurationForm.get('maxSms').clearValidators();
}
this.defaultTenantProfileConfigurationFormGroup.get('maxSms').updateValueAndValidity({emitEvent: false});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.tenantProfileConfigurationForm.get('maxSms').updateValueAndValidity({emitEvent: false});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.defaultTenantProfileConfigurationFormGroup.disable({emitEvent: false});
this.tenantProfileConfigurationForm.disable({emitEvent: false});
} else {
this.defaultTenantProfileConfigurationFormGroup.enable({emitEvent: false});
this.tenantProfileConfigurationForm.enable({emitEvent: false});
}
}
writeValue(value: DefaultTenantProfileConfiguration | null): void {
if (isDefinedAndNotNull(value)) {
this.maxSmsValidation(value.smsEnabled);
this.defaultTenantProfileConfigurationFormGroup.patchValue(value, {emitEvent: false});
this.tenantProfileConfigurationForm.patchValue(value, {emitEvent: false});
}
}
private updateModel() {
let configuration: TenantProfileConfiguration = null;
if (this.defaultTenantProfileConfigurationFormGroup.valid) {
configuration = this.defaultTenantProfileConfigurationFormGroup.getRawValue();
let configuration: DefaultTenantProfileConfiguration = null;
if (this.tenantProfileConfigurationForm.valid) {
configuration = this.tenantProfileConfigurationForm.getRawValue();
}
this.propagateChange(configuration);
}

2
ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts

@ -29,7 +29,7 @@ import { AssetProfileRoutingModule } from './asset-profile-routing.module';
CommonModule,
SharedModule,
HomeComponentsModule,
AssetProfileRoutingModule
AssetProfileRoutingModule,
]
})
export class AssetProfileModule { }

2
ui-ngx/src/app/modules/home/pages/asset/asset.module.ts

@ -35,7 +35,7 @@ import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component';
SharedModule,
HomeComponentsModule,
HomeDialogsModule,
AssetRoutingModule
AssetRoutingModule,
]
})
export class AssetModule { }

2
ui-ngx/src/app/shared/components/time-unit-input.component.ts

@ -178,7 +178,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
this.timeInputForm.disable({emitEvent: false});
} else {
this.timeInputForm.enable({emitEvent: false});
if(this.timeInputForm.invalid) {
if(!this.timeInputForm.valid) {
setTimeout(() => this.updatedModel(this.timeInputForm.value, true))
}
}

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

@ -31,16 +31,41 @@ import {
} from '@shared/models/ace/ace.models';
import { EntitySearchDirection } from '@shared/models/relation.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
configuration: CalculatedFieldConfiguration;
type: CalculatedFieldType;
interface BaseCalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
entityId: EntityId;
}
export interface CalculatedFieldSimple extends BaseCalculatedField {
type: CalculatedFieldType.SIMPLE;
configuration: CalculatedFieldSimpleConfiguration;
}
export interface CalculatedFieldScript extends BaseCalculatedField {
type: CalculatedFieldType.SCRIPT;
configuration: CalculatedFieldScriptConfiguration;
}
export interface CalculatedFieldGeofencing extends BaseCalculatedField {
type: CalculatedFieldType.GEOFENCING;
configuration: CalculatedFieldGeofencingConfiguration;
}
export interface CalculatedFieldPropagation extends BaseCalculatedField {
type: CalculatedFieldType.PROPAGATION;
configuration: CalculatedFieldPropagationConfiguration;
}
export type CalculatedField =
| CalculatedFieldSimple
| CalculatedFieldScript
| CalculatedFieldGeofencing
| CalculatedFieldPropagation;
export enum CalculatedFieldType {
SIMPLE = 'SIMPLE',
SCRIPT = 'SCRIPT',
GEOFENCING = 'GEOFENCING'
GEOFENCING = 'GEOFENCING',
PROPAGATION = 'PROPAGATION'
}
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>(
@ -48,22 +73,66 @@ export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, stri
[CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'],
[CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'],
[CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'],
[CalculatedFieldType.PROPAGATION, 'calculated-fields.type.propagation'],
]
)
export interface CalculatedFieldConfiguration {
type: CalculatedFieldType;
expression?: string;
arguments?: Record<string, CalculatedFieldArgument>;
zoneGroups?: Record<string, CalculatedFieldGeofencing>;
export type CalculatedFieldConfiguration =
| CalculatedFieldSimpleConfiguration
| CalculatedFieldScriptConfiguration
| CalculatedFieldGeofencingConfiguration
| CalculatedFieldPropagationConfiguration;
export interface CalculatedFieldSimpleConfiguration {
type: CalculatedFieldType.SIMPLE;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldSimpleOutput;
}
export interface CalculatedFieldScriptConfiguration {
type: CalculatedFieldType.SCRIPT;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldOutput;
}
export interface CalculatedFieldGeofencingConfiguration {
type: CalculatedFieldType.GEOFENCING;
zoneGroups: Record<string, CalculatedFieldGeofencing>;
scheduledUpdateEnabled: boolean;
scheduledUpdateInterval?: number;
output: CalculatedFieldOutput;
}
interface BasePropagationConfiguration {
type: CalculatedFieldType.PROPAGATION;
direction: EntitySearchDirection;
relationType: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldOutput;
}
export interface PropagationWithNoExpression extends BasePropagationConfiguration {
applyExpressionToResolvedArguments: false;
}
export interface PropagationWithExpression extends BasePropagationConfiguration {
applyExpressionToResolvedArguments: true;
expression: string;
}
export type CalculatedFieldPropagationConfiguration =
| PropagationWithNoExpression
| PropagationWithExpression;
export interface CalculatedFieldOutput {
type: OutputType;
name: string;
scope?: AttributeScope;
}
export interface CalculatedFieldSimpleOutput extends CalculatedFieldOutput {
name: string;
decimalsByDefault?: number;
}
@ -115,6 +184,13 @@ export const GeofencingDirectionLevelTranslations = new Map<EntitySearchDirectio
]
)
export const PropagationDirectionTranslations = new Map<EntitySearchDirection, string>(
[
[EntitySearchDirection.FROM, 'calculated-fields.direction-down-child'],
[EntitySearchDirection.TO, 'calculated-fields.direction-up-parent'],
]
)
export enum ArgumentType {
Attribute = 'ATTRIBUTE',
LatestTelemetry = 'TS_LATEST',

11
ui-ngx/src/app/shared/models/tenant.model.ts

@ -19,6 +19,11 @@ import { TenantId } from './id/tenant-id';
import { TenantProfileId } from '@shared/models/id/tenant-profile-id';
import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { QueueInfo } from '@shared/models/queue.models';
import { FormControl } from '@angular/forms';
export type FormControlsFrom<T> = {
[K in keyof T]-?: FormControl<T[K] | null>;
};
export enum TenantProfileType {
DEFAULT = 'DEFAULT'
@ -101,6 +106,9 @@ export interface DefaultTenantProfileConfiguration {
maxCalculatedFieldsPerEntity: number;
maxArgumentsPerCF: number;
maxRelationLevelPerCfArgument: number;
maxRelatedEntitiesToReturnPerCfArgument: number;
minAllowedScheduledUpdateIntervalInSecForCF: number;
maxDataPointsPerRollingArg: number;
maxStateSizeInKBytes: number;
maxSingleValueArgumentSizeInKBytes: number;
@ -165,6 +173,9 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan
maxCalculatedFieldsPerEntity: 5,
maxArgumentsPerCF: 10,
maxDataPointsPerRollingArg: 1000,
maxRelationLevelPerCfArgument: 10,
maxRelatedEntitiesToReturnPerCfArgument: 100,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
maxStateSizeInKBytes: 32,
maxSingleValueArgumentSizeInKBytes: 2,
calculatedFieldDebugEventsRateLimit: ''

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

@ -1053,7 +1053,8 @@
"type": {
"simple": "Simple",
"script": "Script",
"geofencing" : "Geofencing"
"geofencing" : "Geofencing",
"propagation": "Propagation"
},
"arguments": "Arguments",
"decimals-by-default": "Decimals by default",
@ -1063,6 +1064,7 @@
"datasource": "Datasource",
"add-argument": "Add argument",
"test-script-function": "Test script function",
"test-expression-function": "Test expression function",
"no-arguments": "No arguments configured",
"argument-settings": "Argument settings",
"argument-current": "Current entity",
@ -1138,14 +1140,26 @@
"level": "Level",
"direction-level": "Direction",
"direction-up": "Up",
"direction-up-parent": "Up to parent",
"direction-down": "Down",
"direction-down-child": "Down to child",
"add-level": "Add level",
"delete-level": "Delete level",
"no-level": "No level configured",
"levels-required": "At least one level must be configured.",
"max-allowed-levels-error": "Relation level exceeds the maximum allowed.",
"propagation-path-related-entities": "Propagation path to related entities",
"propagate-type": {
"arguments-only": "Arguments only",
"expression-result": "Expression result"
},
"data-propagate": "Data to propagate",
"output-key": "Output key",
"copy-output-key": "Copy output key",
"hint": {
"arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.",
"arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.",
"arguments-propagate-argument-entity-type": "Entity type is incompatible with 'Arguments only' propagation.",
"arguments-empty": "Arguments should not be empty.",
"expression-required": "Expression is required.",
"expression-invalid": "Expression is invalid",
@ -1155,6 +1169,12 @@
"argument-name-duplicate": "Argument with such name already exists.",
"argument-name-max-length": "Argument name should be less than 256 characters.",
"argument-name-forbidden": "Argument name is reserved and cannot be used.",
"output-key-required": "Output key is required.",
"output-key-pattern": "Output key is invalid.",
"output-key-duplicate": "Key with such name already exists.",
"output-key-max-length": "Output key should be less than 256 characters.",
"output-key-forbidden": "Output key is reserved and cannot be used.",
"entity-type-required": "Entity type is required",
"name-required": "Mame is required.",
"name-pattern": "Name is invalid.",
"name-duplicate": "Name with such name already exists.",
@ -1180,7 +1200,9 @@
"max-geofencing-zone": "Maximum number of geofencing zones reached.",
"zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.",
"zone-group-refresh-interval-required": "Zone groups refresh interval is required.",
"zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second."
"zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second.",
"propagation-path-related-entities": "Defines a direct, single-level path to a related entity based on the selected direction and relation type.",
"data-propagate": "Defines the data to be propagated from the arguments configured below. 'Arguments only' uses the retrieved data directly, while 'Expression result' calculates a new value from that data."
}
},
"ai-models": {
@ -5930,6 +5952,10 @@
"ws-limit-max-subscriptions-per-regular-user": "Subscriptions per regular user maximum number",
"ws-limit-max-subscriptions-per-public-user": "Subscriptions per public user maximum number",
"ws-limit-updates-per-session": "WS updates per session",
"relation-search-entity-limit": "Relation search entity limit",
"relation-search-entity-limit-hint": "Limits the number of entities resolved at the last level of the relation path. Applies to 'Related entities' arguments and Propagation fields.",
"relation-search-entity-limit-required": "Relation search entity limit",
"relation-search-entity-limit-range": "Relation search entity limit can't be less than '1'",
"rate-limits": {
"add-limit": "Add limit",
"and-also-less-than": "and also less than",

Loading…
Cancel
Save