Browse Source

Merge pull request #14253 from irynamatveieva/feature/entity-agg-cf

Entity aggregation calculated field
pull/14364/head
Viacheslav Klimov 11 months ago
committed by GitHub
parent
commit
77e7a623d5
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. 8
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  3. 13
      application/src/main/java/org/thingsboard/server/actors/app/AppActor.java
  4. 9
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  5. 18
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  6. 2
      application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java
  7. 1
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  8. 5
      application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
  9. 75
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  10. 7
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
  11. 4
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
  12. 23
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  13. 7
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  14. 12
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
  15. 10
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  16. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
  17. 5
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  18. 61
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  19. 6
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  20. 8
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java
  21. 36
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java
  22. 45
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java
  23. 87
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java
  24. 271
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java
  25. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  26. 1
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  27. 40
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  28. 35
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  29. 2
      application/src/main/resources/thingsboard.yml
  30. 1
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  31. 255
      application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
  32. 57
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  33. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
  34. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java
  35. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
  36. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  37. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  38. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  39. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  40. 1
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java
  41. 96
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java
  42. 67
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java
  43. 29
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java
  44. 108
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java
  45. 68
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java
  46. 47
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java
  47. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java
  48. 47
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java
  49. 53
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java
  50. 31
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java
  51. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java
  52. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java
  53. 51
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java
  54. 2
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  55. 128
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java
  56. 168
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java
  57. 9
      common/proto/src/main/proto/queue.proto
  58. 23
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  59. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  60. 1
      ui-ngx/src/app/core/auth/auth.reducer.ts
  61. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  62. 70
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  63. 36
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  64. 9
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts
  65. 71
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts
  66. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts
  67. 7
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  68. 133
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
  69. 411
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
  70. 49
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts
  71. 30
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts
  72. 92
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html
  73. 41
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts
  74. 20
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html
  75. 18
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts
  76. 42
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module.ts
  77. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  78. 13
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  79. 1
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
  80. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts
  81. 32
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html
  82. 1
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts
  83. 8
      ui-ngx/src/app/shared/components/time-unit-input.component.html
  84. 98
      ui-ngx/src/app/shared/components/time-unit-input.component.ts
  85. 78
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  86. 2
      ui-ngx/src/app/shared/models/tenant.model.ts
  87. 42
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -46,6 +46,12 @@ SET profile_data = jsonb_set(
WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(60)
END,
'minAllowedAggregationIntervalInSecForCF',
CASE
WHEN (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(60)
END
)
),
@ -59,6 +65,8 @@ WHERE NOT (
(profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
AND
(profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
AND
(profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF'
);
-- UPDATE TENANT PROFILE CONFIGURATION END

8
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -97,8 +97,8 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.rule.RuleNodeStateService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
@ -664,6 +664,10 @@ public class ActorSystemContext {
@Getter
private long cfCalculationResultTimeout;
@Value("${actors.calculated_fields.check_interval:60}")
@Getter
private long cfCheckInterval;
@Value("${actors.alarms.reevaluation_interval:120}")
@Getter
private long alarmRulesReevaluationInterval;
@ -851,7 +855,7 @@ public class ActorSystemContext {
if (arguments != null) {
eventBuilder.arguments(JacksonUtil.toString(
arguments.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toTbelCfArg()))
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue()))
));
}
if (result != null) {

13
application/src/main/java/org/thingsboard/server/actors/app/AppActor.java

@ -32,6 +32,7 @@ import org.thingsboard.server.actors.tenant.TenantActor;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.MsgType;
@ -165,6 +166,18 @@ public class AppActor extends ContextAwareActor {
private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) {
TbActorRef target = null;
if (TenantId.SYS_TENANT_ID.equals(msg.getTenantId())) {
if (systemContext.isTenantComponentsInitEnabled()) {
if (msg.getEntityId() instanceof TenantProfileId tenantProfileId) {
tenantService.findTenantIdsByTenantProfileId(tenantProfileId).forEach(tenantId -> {
getOrCreateTenantActor(tenantId).ifPresentOrElse(tenantActor -> {
log.debug("[{}] Sending component lifecycle msg for tenant.", tenantId);
tenantActor.tellWithHighPriority(msg);
}, () -> {
log.debug("Ignoring component lifecycle msg for tenant {} because it is not managed by this service", tenantId);
});
});
}
}
if (!msg.getEntityId().getEntityType().isOneOf(EntityType.TENANT_PROFILE, EntityType.TB_RESOURCE)) {
log.warn("Message has system tenant id: {}", msg);
}

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

@ -123,9 +123,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state != null) {
state.setCtx(msg.getCtx(), actorCtx);
state.setPartition(msg.getPartition());
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) {
relatedEntitiesAggState.scheduleReevaluation();
}
states.put(cfId, state);
} else {
removeState(cfId);
@ -136,7 +133,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
log.debug("Processing CF state partition restore msg: {}", msg);
for (CalculatedFieldState state : states.values()) {
if (msg.getPartition().equals(state.getPartition())) {
state.init();
state.init(true);
}
}
}
@ -451,7 +448,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
state.init();
state.init(false);
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
@ -500,7 +497,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
} else {
if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) {
String errorMsg = ctx.isInitialized() ? state.getReadinessStatus().errorMsg() : "Calculated field state is not initialized!";
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg);
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg);
}
callback.onSuccess();
}

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

@ -71,6 +71,7 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@ -83,6 +84,7 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Stream;
import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
@ -187,7 +189,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} catch (Exception e) {
log.warn("[{}] Failed to trigger CFs reevaluation", tenantId, e);
}
}, systemContext.getAlarmRulesReevaluationInterval(), systemContext.getAlarmRulesReevaluationInterval(), TimeUnit.SECONDS);
}, systemContext.getCfCheckInterval(), systemContext.getCfCheckInterval(), TimeUnit.SECONDS);
}
public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException {
@ -222,6 +224,12 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
default -> msg.getCallback().onSuccess();
}
}
case TENANT_PROFILE -> {
switch (event) {
case UPDATED -> onTenantProfileUpdated(msg.getData(), msg.getCallback());
default -> msg.getCallback().onSuccess();
}
}
default -> msg.getCallback().onSuccess();
}
}
@ -247,6 +255,14 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
callback.onSuccess();
}
private void onTenantProfileUpdated(ComponentLifecycleMsg msg, TbCallback callback) {
Stream.concat(
calculatedFields.values().stream(),
entityIdCalculatedFields.values().stream().flatMap(Collection::stream)
).forEach(CalculatedFieldCtx::updateTenantProfileProperties);
callback.onSuccess();
}
private void onEntityCreated(ComponentLifecycleMsg msg, TbCallback callback) {
EntityId entityId = msg.getEntityId();
EntityId profileId = getProfileId(tenantId, entityId);

2
application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java

@ -350,7 +350,7 @@ public class TenantActor extends RuleChainManagerActor {
}
}
if (cfActor != null) {
if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER)) {
if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT_PROFILE)) {
cfActor.tellWithHighPriority(new CalculatedFieldEntityLifecycleMsg(tenantId, msg));
}
}

1
application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java

@ -165,6 +165,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF());
systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument());
systemParams.setMinAllowedDeduplicationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedDeduplicationIntervalInSecForCF());
systemParams.setMinAllowedAggregationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedAggregationIntervalInSecForCF());
systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId()));
}
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))

5
application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java

@ -164,9 +164,14 @@ public class TenantProfileController extends BaseController {
" \"warnThreshold\": 0,\n" +
" \"maxCalculatedFieldsPerEntity\": 5,\n" +
" \"maxArgumentsPerCF\": 10,\n" +
" \"minAllowedScheduledUpdateIntervalInSecForCF\": 60,\n" +
" \"maxRelationLevelPerCfArgument\": 10,\n" +
" \"maxRelatedEntitiesToReturnPerCfArgument\": 100,\n" +
" \"maxDataPointsPerRollingArg\": 1000,\n" +
" \"maxStateSizeInKBytes\": 32,\n" +
" \"maxSingleValueArgumentSizeInKBytes\": 2" +
" \"minAllowedDeduplicationIntervalInSecForCF\": 60" +
" \"minAllowedAggregationIntervalInSecForCF\": 60" +
" }\n" +
" },\n" +
" \"default\": false\n" +

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

@ -28,7 +28,11 @@ import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
@ -49,6 +53,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService;
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.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry;
import java.util.Collections;
import java.util.HashMap;
@ -57,6 +62,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@ -66,7 +72,10 @@ import static org.thingsboard.server.common.data.cf.configuration.geofencing.Ent
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformAggMetricArgument;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformAggregationArgument;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformTsRollingArgument;
@Data
@Slf4j
@ -100,6 +109,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts);
case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts);
case RELATED_ENTITIES_AGGREGATION -> fetchRelatedEntitiesAggArguments(ctx, entityId, ts);
case ENTITY_AGGREGATION -> fetchEntityAggArguments(ctx, entityId, ts);
};
if (ctx.getCfType() == PROPAGATION) {
argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId));
@ -133,19 +143,21 @@ public abstract class AbstractCalculatedFieldProcessingService {
return argFutures.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey, // Keep the key as is
entry -> {
try {
return entry.getValue().get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException("Failed to fetch " + entry.getKey() + ": " + cause.getMessage(), cause);
} catch (InterruptedException e) {
throw new RuntimeException("Failed to fetch" + entry.getKey(), e);
}
}
entry -> resolveArgumentValue(entry.getKey(), entry.getValue())
));
}
protected ArgumentEntry resolveArgumentValue(String key, ListenableFuture<ArgumentEntry> future) {
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException("Failed to fetch " + key + ": " + cause.getMessage(), cause);
} catch (InterruptedException e) {
throw new RuntimeException("Failed to fetch" + key, e);
}
}
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());
@ -186,6 +198,17 @@ public abstract class AbstractCalculatedFieldProcessingService {
));
}
protected Map<String, ListenableFuture<ArgumentEntry>> fetchEntityAggArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
if (!(ctx.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration config)) {
return Collections.emptyMap();
}
return config.getArguments().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> fetchTimeSeries(ctx.getTenantId(), entityId, entry.getValue(), config.getInterval(), ts)
));
}
protected ListenableFuture<List<EntityId>> resolveRelatedEntities(TenantId tenantId, EntityId entityId, RelationPathLevel relation) {
Predicate<EntityRelation> filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo());
ListenableFuture<List<EntityRelation>> relationsFut = relationService.findFilteredRelationsByPathQueryAsync(tenantId, new EntityRelationPathQuery(entityId, List.of(relation)), filter);
@ -285,17 +308,26 @@ public abstract class AbstractCalculatedFieldProcessingService {
};
}
protected ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) {
AggFunction function = metric.getFunction();
long intervalMs = interval.getEndTs() - interval.getStartTs();
BaseReadTsKvQuery query = new BaseReadTsKvQuery(argKey, interval.getStartTs(), interval.getEndTs(), intervalMs, 1, Aggregation.valueOf(function.name()));
ListenableFuture<ArgumentEntry> argumentEntryFut = fetchTimeSeriesInternal(tenantId, entityId, query, timeSeries -> transformAggMetricArgument(timeSeries, argKey, metric));
return resolveArgumentValue(argKey, argumentEntryFut);
}
private ListenableFuture<ArgumentEntry> fetchTimeSeries(TenantId tenantId, EntityId entityId, Argument argument, AggInterval interval, long queryEndTs) {
long intervalStartTs = interval.getCurrentIntervalStartTs();
long intervalEndTs = interval.getCurrentIntervalEndTs();
ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), intervalStartTs, queryEndTs, 0, 1, Aggregation.NONE);
return fetchTimeSeriesInternal(tenantId, entityId, query, timeSeries -> transformAggregationArgument(timeSeries, intervalStartTs, intervalEndTs));
}
private ListenableFuture<ArgumentEntry> fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument, long queryEndTs) {
long argTimeWindow = argument.getTimeWindow() == 0 ? queryEndTs : argument.getTimeWindow();
long startInterval = queryEndTs - argTimeWindow;
ReadTsKvQuery query = buildTsRollingQuery(tenantId, argument, startInterval, queryEndTs);
log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query);
ListenableFuture<List<TsKvEntry>> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
return Futures.transform(tsRollingFuture, tsRolling -> {
log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query);
return ArgumentEntry.createTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow);
}, calculatedFieldCallbackExecutor);
return fetchTimeSeriesInternal(tenantId, entityId, query, tsRolling -> transformTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow));
}
private ListenableFuture<ArgumentEntry> fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) {
@ -321,6 +353,15 @@ public abstract class AbstractCalculatedFieldProcessingService {
}, calculatedFieldCallbackExecutor));
}
private ListenableFuture<ArgumentEntry> fetchTimeSeriesInternal(TenantId tenantId, EntityId entityId, ReadTsKvQuery query, Function<List<TsKvEntry>, ArgumentEntry> transformArgument) {
log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query);
ListenableFuture<List<TsKvEntry>> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
return Futures.transform(tsRollingFuture, tsRolling -> {
log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query);
return transformArgument.apply(tsRolling);
}, calculatedFieldCallbackExecutor);
}
private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) {
long maxDataPoints = apiLimitService.getLimit(
tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);

7
application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java

@ -17,14 +17,17 @@ package org.thingsboard.server.service.cf;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
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 org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
public interface CalculatedFieldCache {
@ -38,7 +41,7 @@ public interface CalculatedFieldCache {
List<CalculatedFieldCtx> getCalculatedFieldCtxsByEntityId(EntityId entityId);
List<CalculatedFieldCtx> getAggCalculatedFieldCtxsByFilter(Predicate<CalculatedFieldCtx> relatedEntityFilter);
Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType);
boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter);
@ -48,6 +51,8 @@ public interface CalculatedFieldCache {
void evict(CalculatedFieldId calculatedFieldId);
void handleTenantProfileUpdate(TenantProfileId tenantProfileId);
EntityId getProfileId(TenantId tenantId, EntityId entityId);
Set<EntityId> getDynamicEntities(TenantId tenantId, EntityId entityId);

4
application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.cf;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -25,6 +26,7 @@ import org.thingsboard.server.common.msg.queue.TbCallback;
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.aggregation.single.AggIntervalEntry;
import java.util.List;
import java.util.Map;
@ -39,6 +41,8 @@ public interface CalculatedFieldProcessingService {
Map<String, ArgumentEntry> fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map<String, Argument> arguments);
ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval);
void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback);
void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List<CalculatedFieldEntityCtxId> linkedCalculatedFields, TbCallback callback);

23
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java

@ -24,6 +24,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@ -33,8 +34,10 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
@ -49,6 +52,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
@Service
@Slf4j
@ -60,6 +64,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
private final CalculatedFieldService calculatedFieldService;
private final TbAssetProfileCache assetProfileCache;
private final TbDeviceProfileCache deviceProfileCache;
private final TbTenantProfileCache tenantProfileCache;
@Lazy
private final ActorSystemContext systemContext;
private final OwnerService ownerService;
@ -146,12 +151,10 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
}
@Override
public List<CalculatedFieldCtx> getAggCalculatedFieldCtxsByFilter(Predicate<CalculatedFieldCtx> relatedEntityFilter) {
public Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType) {
return calculatedFields.values().stream()
.filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getType()))
.map(cf -> getCalculatedFieldCtx(cf.getId()))
.filter(relatedEntityFilter)
.toList();
.filter(cf -> cfType.equals(cf.getType()))
.map(cf -> getCalculatedFieldCtx(cf.getId()));
}
@Override
@ -228,6 +231,16 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField);
}
@Override
public void handleTenantProfileUpdate(TenantProfileId tenantProfileId) {
calculatedFieldsCtx.values().stream()
.filter(ctx -> {
TenantProfile tenantProfile = tenantProfileCache.get(ctx.getTenantId());
return tenantProfile != null && tenantProfileId.equals(tenantProfile.getId());
})
.forEach(CalculatedFieldCtx::updateTenantProfileProperties);
}
@Override
public EntityId getProfileId(TenantId tenantId, EntityId entityId) {
return switch (entityId.getEntityType()) {

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

@ -24,6 +24,7 @@ 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.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -48,6 +49,7 @@ import org.thingsboard.server.queue.util.TbRuleEngineComponent;
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.aggregation.single.AggIntervalEntry;
import java.util.ArrayList;
import java.util.Collections;
@ -126,6 +128,11 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
return resolveArgumentFutures(argFutures);
}
@Override
public ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) {
return super.fetchMetricDuringInterval(tenantId, entityId, argKey, metric, interval);
}
@Override
public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) {

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

@ -27,6 +27,7 @@ import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -188,8 +189,15 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
}
}
List<CalculatedFieldCtx> cfCtxs = calculatedFieldCache.getAggCalculatedFieldCtxsByFilter(relatedEntityFilter);
for (CalculatedFieldCtx cfCtx : cfCtxs) {
boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter);
if (hasMatchesEntityAggCfs) {
return true;
}
List<CalculatedFieldCtx> relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION)
.filter(relatedEntityFilter)
.toList();
for (CalculatedFieldCtx cfCtx : relatedEntitiesAggregationCfs) {
if (cfCtx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) {
RelationPathLevel relation = aggConfig.getRelation();
EntitySearchDirection inverseDirection = switch (relation.direction()) {

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

@ -18,11 +18,14 @@ package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.JsonNode;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbelCfArg;
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.aggregation.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
@ -39,7 +42,8 @@ import java.util.Map;
@JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"),
@JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING"),
@JsonSubTypes.Type(value = PropagationArgumentEntry.class, name = "PROPAGATION"),
@JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES")
@JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES"),
@JsonSubTypes.Type(value = EntityAggregationArgumentEntry.class, name = "ENTITY_AGGREGATION")
})
public interface ArgumentEntry {
@ -52,6 +56,10 @@ public interface ArgumentEntry {
boolean isEmpty();
default JsonNode jsonValue() {
return JacksonUtil.valueToTree(toTbelCfArg());
}
TbelCfArg toTbelCfArg();
boolean isForceResetPrevious();

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, PROPAGATION, RELATED_ENTITIES
SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION, RELATED_ENTITIES, ENTITY_AGGREGATION
}

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

@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.utils.CalculatedFieldUtils;
import java.io.Closeable;
@ -62,7 +63,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
}
@Override
public void init() {
public void init(boolean restored) {
}
@Override
@ -82,6 +83,8 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
validateNewEntry(key, newEntry);
if (existingEntry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {
relatedEntitiesArgumentEntry.updateEntry(newEntry);
} else if (existingEntry instanceof EntityAggregationArgumentEntry entityAggArgumentEntry) {
entityAggArgumentEntry.updateEntry(newEntry);
} else {
arguments.put(key, newEntry);
}

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

@ -45,6 +45,8 @@ import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSuppor
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -57,6 +59,7 @@ import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
@ -93,19 +96,20 @@ public class CalculatedFieldCtx implements Closeable {
private Output output;
private String expression;
private boolean useLatestTs;
private boolean requiresScheduledReevaluation;
private long lastReevaluationTs;
private ActorSystemContext systemContext;
private TbelInvokeService tbelInvokeService;
private RelationService relationService;
private AlarmSubscriptionService alarmService;
private CalculatedFieldProcessingService cfProcessingService;
private Map<String, CalculatedFieldScriptEngine> tbelExpressions;
private Map<String, ThreadLocal<Expression>> simpleExpressions;
private boolean initialized;
private long maxDataPointsPerRollingArg;
private long maxStateSize;
private long maxSingleValueArgumentSize;
@ -191,7 +195,6 @@ public class CalculatedFieldCtx implements Closeable {
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) {
this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L;
}
this.requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation();
if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) {
this.useLatestTs = aggConfig.isUseLatestTs();
}
@ -199,12 +202,39 @@ public class CalculatedFieldCtx implements Closeable {
this.tbelInvokeService = systemContext.getTbelInvokeService();
this.relationService = systemContext.getRelationService();
this.alarmService = systemContext.getAlarmService();
this.cfProcessingService = systemContext.getCalculatedFieldProcessingService();
this.maxDataPointsPerRollingArg = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); // fixme why tenant profile update is not handled??
this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
}
public boolean isRequiresScheduledReevaluation() {
long now = System.currentTimeMillis();
long cfCheckIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getCfCheckInterval());
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration entityAggregationConfig) {
Watermark watermark = entityAggregationConfig.getWatermark();
if (watermark != null && watermark.getDuration() > 0) {
return true;
}
long intervalEndTs = entityAggregationConfig.getInterval().getCurrentIntervalEndTs();
if (now + cfCheckIntervalMillis >= intervalEndTs) {
return true;
}
}
boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation();
if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) {
long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval());
if (requiresScheduledReevaluation) {
if (now + cfCheckIntervalMillis >= lastReevaluationTs + reevaluationIntervalMillis) {
lastReevaluationTs = now;
return true;
}
return false;
}
}
return requiresScheduledReevaluation;
}
public void init() {
switch (cfType) {
case SCRIPT -> {
@ -245,9 +275,15 @@ public class CalculatedFieldCtx implements Closeable {
});
initialized = true;
}
case ENTITY_AGGREGATION -> initialized = true;
}
}
public void updateTenantProfileProperties() {
this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
}
public double evaluateSimpleExpression(Expression expression, CalculatedFieldState state) {
for (Map.Entry<String, ArgumentEntry> entry : state.getArguments().entrySet()) {
try {
@ -606,6 +642,12 @@ public class CalculatedFieldCtx implements Closeable {
&& (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) {
return true;
}
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig
&& other.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) {
boolean metricsChanged = thisConfig.getMetrics().equals(otherConfig.getMetrics());
boolean watermarkChanged = thisConfig.getWatermark().equals(otherConfig.getWatermark());
return metricsChanged || watermarkChanged;
}
return false;
}
@ -629,6 +671,9 @@ public class CalculatedFieldCtx implements Closeable {
if (hasRelatedEntitiesAggregationConfigurationChanges(other)) {
return true;
}
if (hasEntityAggregationConfigurationChanges(other)) {
return true;
}
return false;
}
@ -648,6 +693,14 @@ public class CalculatedFieldCtx implements Closeable {
return false;
}
private boolean hasEntityAggregationConfigurationChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) {
return !thisConfig.getInterval().equals(otherConfig.getInterval());
}
return false;
}
private boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalMillis != -1;
}

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

@ -28,6 +28,7 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState;
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;
@ -46,7 +47,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg
@Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION"),
@Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION")
@Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION"),
@Type(value = EntityAggregationCalculatedFieldState.class, name = "ENTITY_AGGREGATION")
})
public interface CalculatedFieldState extends Closeable {
@ -61,7 +63,7 @@ public interface CalculatedFieldState extends Closeable {
void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx);
void init();
void init(boolean restored);
Map<String, ArgumentEntry> update(Map<String, ArgumentEntry> arguments, CalculatedFieldCtx ctx);

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

@ -74,6 +74,14 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec());
}
@Override
public void init(boolean restored) {
super.init(restored);
if (restored) {
scheduleReevaluation();
}
}
@Override
public void close() {
super.close();

36
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java

@ -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.
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.single;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AggIntervalEntry {
private Long startTs;
private Long endTs;
public boolean belongsToInterval(long ts) {
return ts >= startTs && ts < endTs;
}
public long getIntervalDuration() {
return endTs - startTs;
}
}

45
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java

@ -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.
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.single;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AggIntervalEntryStatus {
private long lastArgsRefreshTs = -1;
private long lastMetricsEvalTs = -1;
public AggIntervalEntryStatus(long lastArgsRefreshTs) {
this.lastArgsRefreshTs = lastArgsRefreshTs;
}
public boolean intervalPassed(long checkInterval) {
return lastMetricsEvalTs <= System.currentTimeMillis() - checkInterval;
}
@JsonIgnore
public boolean argsUpdated() {
return lastArgsRefreshTs > -1;
}
}

87
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java

@ -0,0 +1,87 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.single;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.Map;
@Data
public class EntityAggregationArgumentEntry implements ArgumentEntry {
private Map<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals;
private boolean forceResetPrevious;
public EntityAggregationArgumentEntry(Map<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals) {
this.aggIntervals = aggIntervals;
}
@Override
public ArgumentEntryType getType() {
return ArgumentEntryType.ENTITY_AGGREGATION;
}
@Override
public Object getValue() {
return aggIntervals;
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
boolean updated = false;
if (entry instanceof EntityAggregationArgumentEntry entityAggEntry) {
aggIntervals.putAll(entityAggEntry.getAggIntervals());
} else if (entry instanceof SingleValueArgumentEntry singleValueArgEntry) {
long entryTs = singleValueArgEntry.getTs();
long argUpdateTs = System.currentTimeMillis();
for (Map.Entry<AggIntervalEntry, AggIntervalEntryStatus> aggIntervalEntry : aggIntervals.entrySet()) {
if (singleValueArgEntry.isForceResetPrevious()) {
aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs);
updated = true;
continue;
}
if (aggIntervalEntry.getKey().belongsToInterval(entryTs)) {
aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs);
return true;
}
}
}
return updated;
}
@Override
public boolean isEmpty() {
return aggIntervals.isEmpty();
}
@Override
public JsonNode jsonValue() {
return JacksonUtil.valueToTree(aggIntervals);
}
@Override
public TbelCfArg toTbelCfArg() {
return null;
}
}

271
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java

@ -0,0 +1,271 @@
/**
* 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.aggregation.single;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbUtils;
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.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultMetricArgumentEntry;
public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldState {
private AggInterval interval;
private long watermarkDuration;
private long checkInterval;
private Map<String, AggMetric> metrics;
private CalculatedFieldProcessingService cfProcessingService;
public EntityAggregationCalculatedFieldState(EntityId entityId) {
super(entityId);
}
@Override
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) {
super.setCtx(ctx, actorCtx);
this.cfProcessingService = ctx.getCfProcessingService();
var configuration = (EntityAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
Watermark watermark = configuration.getWatermark();
watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration());
checkInterval = TimeUnit.SECONDS.toMillis(ctx.getSystemContext().getCfCheckInterval());
interval = configuration.getInterval();
metrics = configuration.getMetrics();
}
@Override
public void init(boolean restored) {
super.init(restored);
if (restored) {
fillMissingIntervals();
}
}
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.ENTITY_AGGREGATION;
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception {
createIntervalIfNotExist();
long now = System.currentTimeMillis();
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results = new HashMap<>();
List<AggIntervalEntry> expiredIntervals = new ArrayList<>();
getIntervals().forEach((intervalEntry, argIntervalStatuses) -> {
processInterval(now, intervalEntry, argIntervalStatuses, expiredIntervals, results);
});
removeExpiredIntervals(expiredIntervals);
Output output = ctx.getOutput();
ArrayNode result = toResult(results, output.getDecimalsByDefault());
if (result.isEmpty()) {
return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY);
}
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder()
.type(output.getType())
.scope(output.getScope())
.result(result)
.build());
}
private void removeExpiredIntervals(List<AggIntervalEntry> expiredIntervals) {
expiredIntervals.forEach(expiredInterval -> {
arguments.values().stream()
.map(EntityAggregationArgumentEntry.class::cast)
.forEach(arg -> arg.getAggIntervals().remove(expiredInterval));
});
}
private void createIntervalIfNotExist() {
AggIntervalEntry currentInterval = new AggIntervalEntry(interval.getCurrentIntervalStartTs(), interval.getCurrentIntervalEndTs());
arguments.forEach((argName, argumentEntry) -> {
var entityAggEntry = (EntityAggregationArgumentEntry) argumentEntry;
entityAggEntry.getAggIntervals().computeIfAbsent(currentInterval, current -> new AggIntervalEntryStatus());
});
}
private void fillMissingIntervals() {
ZoneId zoneId = interval.getZoneId();
long currentIntervalEndTs = interval.getCurrentIntervalEndTs();
Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> intervals = getIntervals();
AggIntervalEntry lastIntervalEntry = intervals.keySet().stream().max(Comparator.comparing(AggIntervalEntry::getEndTs)).orElse(null);
if (lastIntervalEntry == null) {
return;
}
ZonedDateTime nextStart = Instant.ofEpochMilli(lastIntervalEntry.getEndTs()).atZone(zoneId);
ZonedDateTime nextEnd = interval.getNextIntervalStart(nextStart);
while (nextEnd.toInstant().toEpochMilli() <= currentIntervalEndTs) {
long nextStartTs = nextStart.toInstant().toEpochMilli();
long nextEndTs = nextEnd.toInstant().toEpochMilli();
AggIntervalEntry missing = new AggIntervalEntry(nextStartTs, nextEndTs);
arguments.forEach((argName, argumentEntry) -> {
var entityAggEntry = (EntityAggregationArgumentEntry) argumentEntry;
AggIntervalEntryStatus intervalEntryStatus = new AggIntervalEntryStatus(System.currentTimeMillis());
entityAggEntry.getAggIntervals().computeIfAbsent(missing, missingInterval -> intervalEntryStatus);
});
nextStart = nextEnd;
nextEnd = interval.getNextIntervalStart(nextStart);
}
}
private Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> getIntervals() {
Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> intervals = new HashMap<>();
arguments.forEach((argName, entry) -> {
var argEntry = (EntityAggregationArgumentEntry) entry;
argEntry.getAggIntervals().forEach((intervalEntry, status) ->
intervals.computeIfAbsent(intervalEntry, i -> new HashMap<>()).put(argName, status)
);
});
return intervals;
}
private void processInterval(long now,
AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args,
List<AggIntervalEntry> expiredIntervals,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
long startTs = intervalEntry.getStartTs();
long endTs = intervalEntry.getEndTs();
if (now - endTs > watermarkDuration) {
handleExpiredInterval(intervalEntry, args, results);
expiredIntervals.add(intervalEntry);
} else if (now - startTs >= intervalEntry.getIntervalDuration()) {
handleActiveInterval(intervalEntry, args, results);
}
}
private void handleExpiredInterval(AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
args.forEach((argName, argEntryIntervalStatus) -> {
if (argEntryIntervalStatus.getLastArgsRefreshTs() > argEntryIntervalStatus.getLastMetricsEvalTs()) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
processMetric(intervalEntry, argName, false, results);
} else if (argEntryIntervalStatus.getLastMetricsEvalTs() == -1) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
processMetric(intervalEntry, argName, true, results);
}
});
}
private void handleActiveInterval(AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
args.forEach((argName, argEntryIntervalStatus) -> {
if (argEntryIntervalStatus.intervalPassed(checkInterval)) {
if (argEntryIntervalStatus.argsUpdated()) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
argEntryIntervalStatus.setLastArgsRefreshTs(-1);
processMetric(intervalEntry, argName, false, results);
} else if (argEntryIntervalStatus.getLastMetricsEvalTs() == -1) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
processMetric(intervalEntry, argName, true, results);
}
}
});
}
private void processMetric(AggIntervalEntry intervalEntry,
String argName,
boolean useDefault,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
String metricName = findMetricName(argName);
if (metricName != null) {
AggMetric metric = metrics.get(metricName);
String argKey = ctx.getArguments().get(argName).getRefEntityKey().getKey();
ArgumentEntry metricEntry = useDefault
? createDefaultMetricArgumentEntry(argKey, metric)
: cfProcessingService.fetchMetricDuringInterval(ctx.getTenantId(), entityId, argKey, metric, intervalEntry);
if (!metricEntry.isEmpty()) {
results.computeIfAbsent(intervalEntry, i -> new HashMap<>()).put(metricName, metricEntry);
}
}
}
private String findMetricName(String argName) {
return metrics.entrySet().stream()
.filter(e -> ((AggKeyInput) e.getValue().getInput()).getKey().equals(argName))
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
protected ArrayNode toResult(Map<AggIntervalEntry, Map<String, ArgumentEntry>> results, Integer precision) {
ArrayNode result = JacksonUtil.newArrayNode();
results.forEach((interval, args) -> {
ObjectNode metricsNode = JacksonUtil.newObjectNode();
for (Map.Entry<String, ArgumentEntry> entry : args.entrySet()) {
String metricName = entry.getKey();
ArgumentEntry argumentEntry = entry.getValue();
if (!argumentEntry.isEmpty()) {
Object resultValue = argumentEntry.getValue() instanceof Number number
? TbUtils.roundResult(number.doubleValue(), precision)
: argumentEntry.getValue();
metricsNode.put(metricName, JacksonUtil.toString(resultValue));
}
}
if (!metricsNode.isEmpty()) {
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", interval.getEndTs() - 1);
resultNode.set("values", metricsNode);
result.add(resultNode);
}
});
return result;
}
@Override
public boolean isReady() {
return true;
}
}

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

@ -122,8 +122,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
public void init() {
super.init();
public void init(boolean restored) {
super.init(restored);
AtomicBoolean reevalNeeded = new AtomicBoolean(false);
Map<AlarmSeverity, AlarmRule> createRules = configuration.getCreateRules();
for (AlarmSeverity severity : AlarmSeverity.values()) {

1
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java

@ -166,6 +166,7 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
tenantProfileCache.evict(tenantProfileId);
if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED)) {
apiUsageStateService.onTenantProfileUpdate(tenantProfileId);
calculatedFieldCache.handleTenantProfileUpdate(tenantProfileId);
}
} else if (EntityType.TENANT.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
if (TenantId.SYS_TENANT_ID.equals(tenantId)) {

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

@ -21,6 +21,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import org.apache.commons.lang3.math.NumberUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
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;
@ -35,10 +37,17 @@ import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
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.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntryStatus;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState;
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class CalculatedFieldArgumentUtils {
@ -55,6 +64,36 @@ public class CalculatedFieldArgumentUtils {
}
}
public static ArgumentEntry transformTsRollingArgument(List<TsKvEntry> tsRolling, int limit, long argTimeWindow) {
return ArgumentEntry.createTsRollingArgument(tsRolling, limit, argTimeWindow);
}
public static ArgumentEntry transformAggMetricArgument(List<TsKvEntry> timeSeries, String argKey, AggMetric aggMetric) {
if (timeSeries == null || timeSeries.isEmpty()) {
return createDefaultMetricArgumentEntry(argKey, aggMetric);
}
return ArgumentEntry.createSingleValueArgument(timeSeries.get(0));
}
public static ArgumentEntry createDefaultMetricArgumentEntry(String argKey, AggMetric metric) {
Long defaultValue = metric.getDefaultValue();
if (defaultValue != null) {
return ArgumentEntry.createSingleValueArgument(new DoubleDataEntry(argKey, defaultValue.doubleValue()));
}
return new SingleValueArgumentEntry();
}
public static ArgumentEntry transformAggregationArgument(List<TsKvEntry> timeSeries, long startIntervalTs, long endIntervalTs) {
Map<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals = new HashMap<>();
AggIntervalEntry aggIntervalEntry = new AggIntervalEntry(startIntervalTs, endIntervalTs);
if (timeSeries == null || timeSeries.isEmpty()) {
aggIntervals.put(aggIntervalEntry, new AggIntervalEntryStatus());
} else {
aggIntervals.put(aggIntervalEntry, new AggIntervalEntryStatus(System.currentTimeMillis()));
}
return new EntityAggregationArgumentEntry(aggIntervals);
}
public static KvEntry createDefaultKvEntry(Argument argument) {
String key = argument.getRefEntityKey().getKey();
String defaultValue = argument.getDefaultValue();
@ -83,6 +122,7 @@ public class CalculatedFieldArgumentUtils {
case ALARM -> new AlarmCalculatedFieldState(entityId);
case PROPAGATION -> new PropagationCalculatedFieldState(entityId);
case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(entityId);
case ENTITY_AGGREGATION -> new EntityAggregationCalculatedFieldState(entityId);
};
}

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

@ -29,6 +29,7 @@ import org.thingsboard.server.common.util.KvProtoUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.AlarmRuleStateProto;
import org.thingsboard.server.gen.transport.TransportProtos.AlarmStateProto;
import org.thingsboard.server.gen.transport.TransportProtos.ArgumentIntervalProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
@ -47,6 +48,10 @@ import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntryStatus;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
@ -105,6 +110,10 @@ public class CalculatedFieldUtils {
relatedEntitiesArgumentEntry.getEntityInputs()
.forEach((entityId, entry) -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) entry)));
}
case ENTITY_AGGREGATION -> {
EntityAggregationArgumentEntry entityAggregationArgumentEntry = (EntityAggregationArgumentEntry) argEntry;
entityAggregationArgumentEntry.getAggIntervals().forEach((interval, argumentStatus) -> builder.addAggregationArguments(toArgumentIntervalProto(argName, interval, argumentStatus)));
}
}
});
if (state instanceof AlarmCalculatedFieldState alarmState) {
@ -158,6 +167,16 @@ public class CalculatedFieldUtils {
return builder.build();
}
public static ArgumentIntervalProto toArgumentIntervalProto(String argName, AggIntervalEntry intervalEntry, AggIntervalEntryStatus argumentStatus) {
return ArgumentIntervalProto.newBuilder()
.setArgName(argName)
.setStartTs(intervalEntry.getStartTs())
.setEndTs(intervalEntry.getEndTs())
.setLastArgsRefreshTs(argumentStatus.getLastArgsRefreshTs())
.setLastMetricsEvalTs(argumentStatus.getLastMetricsEvalTs())
.build();
}
public static TsRollingArgumentProto toRollingArgumentProto(String argName, TsRollingArgumentEntry entry) {
TsRollingArgumentProto.Builder builder = TsRollingArgumentProto.newBuilder()
.setKey(argName)
@ -204,6 +223,7 @@ public class CalculatedFieldUtils {
case ALARM -> new AlarmCalculatedFieldState(id.entityId());
case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId());
case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(id.entityId());
case ENTITY_AGGREGATION -> new EntityAggregationCalculatedFieldState(id.entityId());
};
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) {
@ -221,6 +241,21 @@ public class CalculatedFieldUtils {
return relatedEntitiesAggState;
}
if (state instanceof EntityAggregationCalculatedFieldState entityAggregationState) {
Map<String, EntityAggregationArgumentEntry> arguments = new HashMap<>();
proto.getAggregationArgumentsList().forEach(argProto -> {
AggIntervalEntry intervalEntry = new AggIntervalEntry(argProto.getStartTs(), argProto.getEndTs());
AggIntervalEntryStatus intervalStatus = new AggIntervalEntryStatus(argProto.getLastArgsRefreshTs(), argProto.getLastMetricsEvalTs());
EntityAggregationArgumentEntry argEntry = arguments.computeIfAbsent(argProto.getArgName(), name -> new EntityAggregationArgumentEntry(new HashMap<>()));
argEntry.getAggIntervals().put(intervalEntry, intervalStatus);
});
entityAggregationState.getArguments().putAll(arguments);
return entityAggregationState;
}
proto.getSingleValueArgumentsList().forEach(argProto ->
state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto)));

2
application/src/main/resources/thingsboard.yml

@ -529,6 +529,8 @@ actors:
configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}"
# Time in seconds to receive calculation result.
calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}"
# Interval in seconds to check calculated fields for re-evaluation interval. 1 minute by default.
check_interval: "${ACTORS_CALCULATED_FIELDS_CHECK_INTERVAL_SEC:60}"
alarms:
# Interval in seconds to re-evaluate Alarm rules that have a time schedule. 2 minutes by default.
reevaluation_interval: "${ACTORS_ALARMS_REEVALUATION_INTERVAL_SEC:120}"

1
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -86,6 +86,7 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await;
@Slf4j
@DaoSqlTest
@TestPropertySource(properties = {
"actors.calculated_fields.check_interval=1",
"actors.alarms.reevaluation_interval=1"
})
public class AlarmRulesTest extends AbstractControllerTest {

255
application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java

@ -0,0 +1,255 @@
/**
* 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.cf;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
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.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.CustomInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL;
@DaoSqlTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@TestPropertySource(properties = {
"actors.calculated_fields.check_interval=1"
})
public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest {
private Tenant savedTenant;
@Before
public void beforeEach() throws Exception {
loginSysAdmin();
updateDefaultTenantProfileConfig(tenantProfileConfig -> {
tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1);
tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1);
});
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
savedTenant = saveTenant(tenant);
assertThat(savedTenant).isNotNull();
User tenantAdmin = new User();
tenantAdmin.setAuthority(Authority.TENANT_ADMIN);
tenantAdmin.setTenantId(savedTenant.getId());
tenantAdmin.setEmail("tenant@thingsboard.org");
tenantAdmin.setFirstName("John");
tenantAdmin.setLastName("Doe");
createUserAndLogin(tenantAdmin, "testPassword");
}
@After
public void afterTest() throws Exception {
loginSysAdmin();
deleteTenant(savedTenant.getId());
}
@Test
public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long intervalEndTs = customInterval.getCurrentIntervalEndTs();
CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null);
long interval = customInterval.getCurrentIntervalDurationMillis();
await().alias("create CF and no telemetry during interval -> save metric with default value")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999");
});
}
@Test
public void testCreateCfWithoutWatermark_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000;
long tsInInterval_1 = currentIntervalStartTs + 1000;
long tsInInterval_2 = currentIntervalStartTs + 500;
long tsInInterval_3 = currentIntervalStartTs + 200;
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis();
CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null);
await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
});
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1));
await().alias("update telemetry that belongs to previous interval -> no aggregation since watermark is not set ")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
});
}
@Test
public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000L;
long tsInInterval_1 = currentIntervalStartTs + 1000L;
long tsInInterval_2 = currentIntervalStartTs + 500L;
long tsInInterval_3 = currentIntervalStartTs + 200L;
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis();
Watermark watermark = new Watermark(10);
CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, watermark);
await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
});
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1));
await().alias("update telemetry during watermark -> perform aggregation")
.atMost(2 * 10, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600");
});
}
private CalculatedField createTotalConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) {
Map<String, Argument> arguments = new HashMap<>();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
arguments.put("en", argument);
Map<String, AggMetric> aggMetrics = new HashMap<>();
AggMetric consumption = new AggMetric();
consumption.setFunction(AggFunction.SUM);
consumption.setInput(new AggKeyInput("en"));
consumption.setDefaultValue(9999L);
aggMetrics.put("consumption", consumption);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
return createAggCf("Consumption per minute", entityId,
aggInterval,
watermark,
arguments,
aggMetrics,
output);
}
private CalculatedField createAggCf(String name,
EntityId entityId,
AggInterval aggInterval,
Watermark watermark,
Map<String, Argument> inputs,
Map<String, AggMetric> metrics,
Output output) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setName(name);
calculatedField.setEntityId(entityId);
calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION);
EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration();
configuration.setArguments(inputs);
configuration.setMetrics(metrics);
configuration.setInterval(aggInterval);
if (watermark != null) {
configuration.setWatermark(watermark);
}
configuration.setOutput(output);
calculatedField.setConfiguration(configuration);
calculatedField.setDebugSettings(DebugSettings.all());
return saveCalculatedField(calculatedField);
}
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);
}
}

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

@ -32,6 +32,11 @@ import org.thingsboard.server.common.data.cf.configuration.PropagationCalculated
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;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
@ -45,6 +50,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
@ -166,6 +172,34 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
@Test
public void testSaveEntityAggregationCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.ENTITY_AGGREGATION);
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(getEntityAggregationCalculatedFieldConfig());
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");
@ -237,6 +271,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig());
case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig());
case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig());
case ENTITY_AGGREGATION -> calculatedField.setConfiguration(getEntityAggregationCalculatedFieldConfig());
}
calculatedField.setVersion(1L);
return calculatedField;
@ -287,6 +322,28 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
return config;
}
private CalculatedFieldConfiguration getEntityAggregationCalculatedFieldConfig() {
var config = new EntityAggregationCalculatedFieldConfiguration();
Argument energyArgument = new Argument();
energyArgument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
config.setArguments(Map.of("en", energyArgument));
AggMetric metric = new AggMetric();
metric.setInput(new AggKeyInput("en"));
metric.setDefaultValue(9999L);
config.setMetrics(Map.of("consumption", metric));
config.setWatermark(new Watermark(TimeUnit.DAYS.toSeconds(1)));
config.setInterval(new HourInterval("Europe/Kiev", TimeUnit.MINUTES.toSeconds(15)));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
return config;
}
private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();

2
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java

@ -105,7 +105,7 @@ public class GeofencingCalculatedFieldStateTest {
ctx.init();
state = new GeofencingCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

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

@ -105,7 +105,7 @@ public class PropagationCalculatedFieldStateTest {
state = new PropagationCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

2
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java

@ -88,7 +88,7 @@ public class ScriptCalculatedFieldStateTest {
ctx.init();
state = new ScriptCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

2
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java

@ -80,7 +80,7 @@ public class SimpleCalculatedFieldStateTest {
ctx.init();
state = new SimpleCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

1
common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java

@ -41,5 +41,6 @@ public class SystemParams {
int minAllowedScheduledUpdateIntervalInSecForCF;
int maxRelationLevelPerCfArgument;
long minAllowedDeduplicationIntervalInSecForCF;
long minAllowedAggregationIntervalInSecForCF;
TrendzSettings trendzSettings;
}

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

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

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

@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@ -44,7 +45,8 @@ import java.util.stream.Collectors;
@Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION"),
@Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION")
@Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION"),
@Type(value = EntityAggregationCalculatedFieldConfiguration.class, name = "ENTITY_AGGREGATION")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CalculatedFieldConfiguration {

1
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java

@ -27,5 +27,6 @@ public class AggMetric {
private AggFunction function;
private String filter;
private AggInput input;
private Long defaultValue;
}

96
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.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.aggregation.single;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
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;
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.Output;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import java.util.Map;
@Data
public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
private Map<String, Argument> arguments;
@Valid
@NotEmpty
private Map<String, AggMetric> metrics;
@Valid
@NotNull
private AggInterval interval;
@Valid
private Watermark watermark;
@Valid
@NotNull
private Output output;
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.ENTITY_AGGREGATION;
}
@Override
public void validate() {
validateArguments();
validateMetrics();
validateInterval();
}
private void validateArguments() {
if (arguments.containsKey("ctx")) {
throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used.");
}
if (arguments.values().stream().anyMatch(argument -> !ArgumentType.TS_LATEST.equals(argument.getRefEntityKey().getType()))) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' support only TS_LATEST arguments.");
}
}
private void validateMetrics() {
if (metrics == null || metrics.isEmpty()) {
throw new IllegalArgumentException("Metrics map cannot be empty.");
}
for (AggMetric metric : metrics.values()) {
if (metric.getInput() instanceof AggKeyInput aggKeyInput) {
if (!arguments.containsKey(aggKeyInput.getKey())) {
throw new IllegalArgumentException(
"Metric references unknown argument: '" + aggKeyInput.getKey() + "'."
);
}
} else {
throw new IllegalArgumentException("Metric key can only refer to argument.");
}
}
}
private void validateInterval() {
if (interval == null) {
throw new IllegalArgumentException("Interval must be defined.");
}
interval.validate();
}
}

67
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java

@ -0,0 +1,67 @@
/**
* 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.aggregation.single.interval;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = HourInterval.class, name = "HOUR"),
@JsonSubTypes.Type(value = DayInterval.class, name = "DAY"),
@JsonSubTypes.Type(value = WeekInterval.class, name = "WEEK"),
@JsonSubTypes.Type(value = WeekSunSatInterval.class, name = "WEEK_SUN_SAT"),
@JsonSubTypes.Type(value = MonthInterval.class, name = "MONTH"),
@JsonSubTypes.Type(value = QuarterInterval.class, name = "QUARTER"),
@JsonSubTypes.Type(value = YearInterval.class, name = "YEAR"),
@JsonSubTypes.Type(value = CustomInterval.class, name = "CUSTOM")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface AggInterval {
@JsonIgnore
AggIntervalType getType();
@JsonIgnore
ZoneId getZoneId();
@JsonIgnore
long getCurrentIntervalDurationMillis();
@JsonIgnore
long getCurrentIntervalStartTs();
long getDateTimeIntervalStartTs(ZonedDateTime dateTime);
@JsonIgnore
long getCurrentIntervalEndTs();
long getDateTimeIntervalEndTs(ZonedDateTime dateTime);
ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart);
void validate();
}

29
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java

@ -0,0 +1,29 @@
/**
* 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.aggregation.single.interval;
public enum AggIntervalType {
HOUR,
DAY,
WEEK,
WEEK_SUN_SAT,
MONTH,
QUARTER,
YEAR,
CUSTOM
}

108
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java

@ -0,0 +1,108 @@
/**
* 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.aggregation.single.interval;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.concurrent.TimeUnit;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@AllArgsConstructor
@NoArgsConstructor
public abstract class BaseAggInterval implements AggInterval {
@NotBlank
protected String tz;
protected Long offsetSec; // delay seconds since start of interval
@Override
public ZoneId getZoneId() {
return ZoneId.of(tz);
}
protected long getOffsetSafe() {
return offsetSec != null ? offsetSec : 0L;
}
@Override
public long getCurrentIntervalDurationMillis() {
return getCurrentIntervalEndTs() - getCurrentIntervalStartTs();
}
@Override
public long getCurrentIntervalStartTs() {
ZoneId zoneId = getZoneId();
ZonedDateTime now = ZonedDateTime.now(zoneId);
return getDateTimeIntervalStartTs(now);
}
@Override
public long getDateTimeIntervalStartTs(ZonedDateTime dateTime) {
long offset = getOffsetSafe();
ZonedDateTime shiftedNow = dateTime.minusSeconds(offset);
ZonedDateTime alignedStart = getAlignedBoundary(shiftedNow, false);
ZonedDateTime actualStart = alignedStart.plusSeconds(offset);
return actualStart.toInstant().toEpochMilli();
}
@Override
public long getCurrentIntervalEndTs() {
ZoneId zoneId = getZoneId();
ZonedDateTime now = ZonedDateTime.now(zoneId);
return getDateTimeIntervalEndTs(now);
}
@Override
public long getDateTimeIntervalEndTs(ZonedDateTime dateTime) {
long offset = getOffsetSafe();
ZonedDateTime shiftedNow = dateTime.minusSeconds(offset);
ZonedDateTime alignedEnd = getAlignedBoundary(shiftedNow, true);
ZonedDateTime actualEnd = alignedEnd.plusSeconds(offset);
return actualEnd.toInstant().toEpochMilli();
}
protected abstract ZonedDateTime alignToIntervalStart(ZonedDateTime reference);
protected ZonedDateTime getAlignedBoundary(ZonedDateTime reference, boolean next) {
ZonedDateTime base = alignToIntervalStart(reference);
return next ? getNextIntervalStart(base) : base;
}
@Override
public void validate() {
try {
getZoneId();
} catch (Exception ex) {
throw new IllegalArgumentException("Invalid timezone in interval: " + ex.getMessage());
}
if (offsetSec != null) {
if (offsetSec < 0) {
throw new IllegalArgumentException("Offset cannot be negative.");
}
if (TimeUnit.SECONDS.toMillis(offsetSec) >= getCurrentIntervalDurationMillis()) {
throw new IllegalArgumentException("Offset must be greater than interval duration.");
}
}
}
}

68
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java

@ -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.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.time.Duration;
import java.time.ZonedDateTime;
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class CustomInterval extends BaseAggInterval {
@NotNull
@Min(1)
private Long durationSec;
public CustomInterval(String tz, Long offsetSec, Long durationSec) {
super(tz, offsetSec);
this.durationSec = durationSec;
}
@Override
public AggIntervalType getType() {
return AggIntervalType.CUSTOM;
}
@Override
public long getCurrentIntervalDurationMillis() {
return getDurationMillis();
}
private long getDurationMillis() {
return Duration.ofSeconds(durationSec).toMillis();
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
ZonedDateTime localMidnight = reference.toLocalDate().atStartOfDay(reference.getZone());
long secondsFromMidnight = Duration.between(localMidnight, reference).getSeconds();
long alignedSecondsFromMidnight = (secondsFromMidnight / durationSec) * durationSec;
return localMidnight.plusSeconds(alignedSecondsFromMidnight);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusSeconds(durationSec);
}
}

47
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java

@ -0,0 +1,47 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
@Data
@NoArgsConstructor
public class DayInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.DAY;
}
public DayInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusDays(1);
}
}

49
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.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.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class HourInterval extends BaseAggInterval {
public HourInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
public AggIntervalType getType() {
return AggIntervalType.HOUR;
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.truncatedTo(ChronoUnit.HOURS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusHours(1);
}
}

47
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java

@ -0,0 +1,47 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
@Data
@NoArgsConstructor
public class MonthInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.MONTH;
}
public MonthInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusMonths(1);
}
}

53
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java

@ -0,0 +1,53 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZonedDateTime;
@Data
@NoArgsConstructor
public class QuarterInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.QUARTER;
}
public QuarterInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
int month = reference.getMonthValue();
int quarterStartMonth = ((month - 1) / 3) * 3 + 1; // 1, 4, 7, 10
return ZonedDateTime.of(
LocalDate.of(reference.getYear(), quarterStartMonth, 1),
LocalTime.MIDNIGHT,
reference.getZone());
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusMonths(3);
}
}

31
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java

@ -0,0 +1,31 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval;
import jakarta.validation.constraints.Min;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Watermark {
@Min(0)
private long duration;
}

49
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.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.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.DayOfWeek;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
@Data
@NoArgsConstructor
public class WeekInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.WEEK;
}
public WeekInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusWeeks(1);
}
}

49
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.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.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.DayOfWeek;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
@Data
@NoArgsConstructor
public class WeekSunSatInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.WEEK_SUN_SAT;
}
public WeekSunSatInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)).truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusWeeks(1);
}
}

51
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java

@ -0,0 +1,51 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZonedDateTime;
@Data
@NoArgsConstructor
public class YearInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.YEAR;
}
public YearInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return ZonedDateTime.of(
LocalDate.of(reference.getYear(), 1, 1),
LocalTime.MIDNIGHT,
reference.getZone());
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusYears(1);
}
}

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

@ -188,6 +188,8 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxSingleValueArgumentSizeInKBytes = 2;
@Schema(example = "60")
private long minAllowedDeduplicationIntervalInSecForCF = 60;
@Schema(example = "60")
private long minAllowedAggregationIntervalInSecForCF = 60;
@Override
public long getProfileThreshold(ApiUsageRecordKey key) {

128
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java

@ -0,0 +1,128 @@
/**
* 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.aggregation.single;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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.Output;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class EntityAggregationCalculatedFieldConfigurationTest {
@Test
void typeShouldBeEntityAggregation() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.ENTITY_AGGREGATION);
}
@ParameterizedTest
@ValueSource(strings = {"ATTRIBUTE", "TS_ROLLING"})
void validateShouldThrowWhenNotTsLatestArgumentUsed(String argumentType) {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.valueOf(argumentType))));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Calculated field with type: '" + cfg.getType() + "' support only TS_LATEST arguments.");
}
@Test
void validateShouldThrowWhenMetricMapIsEmpty() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
cfg.setMetrics(Map.of());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Metrics map cannot be empty.");
}
@Test
void validateShouldThrowWhenMetricInputIsNotAggKeyInput() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
AggMetric metric = new AggMetric();
metric.setInput(new AggFunctionInput()); // cannot be function
cfg.setMetrics(Map.of("m", metric));
cfg.setInterval(new HourInterval("Europe/Kiev", null));
cfg.setOutput(new Output());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Metric key can only refer to argument.");
}
@Test
void validateShouldThrowWhenMetricReferencesUnknownArgument() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
AggMetric metric = new AggMetric();
metric.setInput(new AggKeyInput("unknown"));
cfg.setMetrics(Map.of("m", metric));
cfg.setInterval(new HourInterval("Europe/Kiev", null));
cfg.setOutput(new Output());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Metric references unknown argument: 'unknown'.");
}
@Test
void validateShouldThrowWhenIntervalIsNull() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
cfg.setMetrics(Map.of("m", validMetric()));
cfg.setInterval(null);
cfg.setOutput(new Output());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Interval must be defined.");
}
private Argument validArgument(ArgumentType type) {
Argument a = new Argument();
a.setRefEntityKey(new ReferencedEntityKey("key", type, null));
return a;
}
private AggMetric validMetric() {
AggMetric metric = new AggMetric();
metric.setInput(new AggKeyInput("k"));
return metric;
}
}

168
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java

@ -0,0 +1,168 @@
/**
* 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.aggregation.single.interval;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.function.LongFunction;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class AggIntervalTest {
private static final String TZ = "Europe/Kiev";
@Test
void validateShouldThrowWhenInvalidTimZone() {
AggInterval interval = new HourInterval("TimeZone", null);
assertThatThrownBy(interval::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid timezone in interval: ");
}
@Test
void validateShouldThrowWhenOffsetIsNegative() {
AggInterval interval = new CustomInterval(TZ, -100L, TimeUnit.HOURS.toSeconds(2));
assertThatThrownBy(interval::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Offset cannot be negative.");
}
@Test
void validateShouldThrowWhenOffsetGreaterThanIntervalDuration() {
AggInterval interval = new CustomInterval(TZ, TimeUnit.HOURS.toSeconds(2), TimeUnit.HOURS.toSeconds(2));
assertThatThrownBy(interval::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Offset must be greater than interval duration.");
}
@ParameterizedTest
@MethodSource("intervals")
void testGetStartAndEndWithoutOffset(LongFunction<AggInterval> intervalCreator) {
AggInterval interval = intervalCreator.apply(0L);
ZonedDateTime dateTime = ZonedDateTime.of(
// 2025.11.11 00:00:00
2025, 11, 11, 0, 0, 0, 0, ZoneId.of(TZ)
);
long startTs = interval.getDateTimeIntervalStartTs(dateTime);
long endTs = interval.getDateTimeIntervalEndTs(dateTime);
assertThat(endTs).isGreaterThan(startTs);
assertThat(endTs - startTs).isEqualTo(interval.getCurrentIntervalDurationMillis());
}
@ParameterizedTest
@MethodSource("intervals")
void testApplyOffset(LongFunction<AggInterval> intervalCreator) {
long offsetSec = TimeUnit.MINUTES.toSeconds(15);
AggInterval intervalWithOffset = intervalCreator.apply(offsetSec);
AggInterval intervalNoOffset = intervalCreator.apply(0L);
ZonedDateTime dateTime = ZonedDateTime.of(
// 2025.11.11 11:20:00 - chosen so 15m offset shifts into a new interval
2025, 11, 11, 11, 20, 0, 0, ZoneId.of(TZ)
);
long startWithOffsetTs = intervalWithOffset.getDateTimeIntervalStartTs(dateTime);
long startNoOffsetTs = intervalNoOffset.getDateTimeIntervalStartTs(dateTime);
ZonedDateTime startWithOffset = Instant.ofEpochMilli(startWithOffsetTs).atZone(intervalWithOffset.getZoneId());
ZonedDateTime startNoOffset = Instant.ofEpochMilli(startNoOffsetTs).atZone(intervalNoOffset.getZoneId());
long actualOffset = Duration.between(startNoOffset, startWithOffset).toSeconds();
assertThat(actualOffset).isEqualTo(offsetSec);
}
private static Stream<Arguments> intervals() {
return Stream.of(
Arguments.of((LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)))
);
}
@ParameterizedTest
@MethodSource("nextIntervalFromExactDate")
void testNextIntervalFromExactDate(LongFunction<AggInterval> intervalCreator, Function<ZonedDateTime, ZonedDateTime> expectedDateTimeFunction) {
AggInterval interval = intervalCreator.apply(0L);
ZonedDateTime currentStart = ZonedDateTime.of(
2025, 11, 11, 0, 0, 0, 0, ZoneId.of(TZ)
);
ZonedDateTime nextStart = interval.getNextIntervalStart(currentStart);
assertThat(nextStart).isEqualTo(expectedDateTimeFunction.apply(currentStart));
}
private static Stream<Arguments> nextIntervalFromExactDate() {
return Stream.of(
Arguments.of(
(LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusHours(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusDays(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusWeeks(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusWeeks(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusMonths(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusMonths(3)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusYears(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusHours(4)
)
);
}
}

9
common/proto/src/main/proto/queue.proto

@ -916,6 +916,14 @@ message GeofencingArgumentProto {
repeated GeofencingZoneProto zones = 2;
}
message ArgumentIntervalProto {
string argName = 1;
int64 startTs = 2;
int64 endTs = 3;
int64 lastArgsRefreshTs = 4;
int64 lastMetricsEvalTs = 5;
}
message CalculatedFieldStateProto {
CalculatedFieldEntityCtxIdProto id = 1;
string type = 2;
@ -925,6 +933,7 @@ message CalculatedFieldStateProto {
AlarmStateProto alarmState = 6;
int64 lastArgsUpdateTs = 7;
int64 lastMetricsEvalTs = 8;
repeated ArgumentIntervalProto aggregationArguments = 9;
}
//Used to report session state to tb-Service and persist this state in the cache on the tb-Service level.

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

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalcula
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.cf.CalculatedFieldDao;
@ -31,6 +32,7 @@ import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Component
@ -49,6 +51,7 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
validateSchedulingConfiguration(tenantId, calculatedField);
validateRelationQuerySourceArguments(tenantId, calculatedField);
validateAggregationConfiguration(tenantId, calculatedField);
validateEntityAggregationConfiguration(tenantId, calculatedField);
}
@Override
@ -120,10 +123,24 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
if (!(calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfiguration)) {
return;
}
long minAllowedDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF);
if (aggConfiguration.getDeduplicationIntervalInSec() < minAllowedDeduplicationInterval) {
long minDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF);
if (aggConfiguration.getDeduplicationIntervalInSec() < minDeduplicationInterval) {
throw new IllegalArgumentException("Deduplication interval is less than configured " +
"minimum allowed interval in tenant profile: " + minAllowedDeduplicationInterval);
"minimum allowed interval in tenant profile: " + minDeduplicationInterval);
}
}
private void validateEntityAggregationConfiguration(TenantId tenantId, CalculatedField calculatedField) {
if (!(calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration aggConfiguration)) {
return;
}
long minAggregationIntervalInSec = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedAggregationIntervalInSecForCF);
if (minAggregationIntervalInSec <= 0) {
return;
}
if (aggConfiguration.getInterval().getCurrentIntervalDurationMillis() < TimeUnit.SECONDS.toMillis(minAggregationIntervalInSec)) {
throw new IllegalArgumentException("Aggregation interval duration is less than configured " +
"minimum allowed aggregation interval in tenant profile: " + minAggregationIntervalInSec + " sec.");
}
}

1
ui-ngx/src/app/core/auth/auth.models.ts

@ -32,6 +32,7 @@ export interface SysParamsState {
maxDataPointsPerRollingArg: number;
maxArgumentsPerCF: number;
minAllowedDeduplicationIntervalInSecForCF: number;
minAllowedAggregationIntervalInSecForCF: number;
minAllowedScheduledUpdateIntervalInSecForCF: number;
maxRelationLevelPerCfArgument: number;
ruleChainDebugPerTenantLimitsConfiguration?: string;

1
ui-ngx/src/app/core/auth/auth.reducer.ts

@ -34,6 +34,7 @@ const emptyUserAuthState: AuthPayload = {
maxResourceSize: 0,
maxArgumentsPerCF: 0,
minAllowedDeduplicationIntervalInSecForCF: 0,
minAllowedAggregationIntervalInSecForCF: 0,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
maxRelationLevelPerCfArgument: 0,
maxDataPointsPerRollingArg: 0,

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

@ -41,6 +41,9 @@ import {
import {
RelatedEntitiesAggregationComponentModule
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module';
import {
EntityAggregationComponentModule
} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module';
@NgModule({
declarations: [
@ -56,6 +59,7 @@ import {
SimpleConfigurationModule,
PropagationConfigurationModule,
RelatedEntitiesAggregationComponentModule,
EntityAggregationComponentModule,
],
exports: [
CalculatedFieldDialogComponent,

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

@ -74,25 +74,27 @@
}
</ng-container>
<ng-container [formGroup]="refEntityKeyFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (type of argumentTypes; track type) {
<mat-option [value]="type">{{ ArgumentTypeTranslations.get(type) | translate }}</mat-option>
@if (!hiddenEntityKeyTypes) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (type of argumentTypes; track type) {
<mat-option [value]="type">{{ ArgumentTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
@if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-select>
@if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
</mat-form-field>
</div>
}
@if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) {
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) {
<div class="tb-form-row">
@ -149,21 +151,23 @@
}"></ng-container>
}
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
<div class="tb-form-row">
<div class="fixed-title-width" [class.tb-required]="defaultValueRequired">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1 overflow-hidden" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.default-value-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
@if (!hiddenDefaultValue) {
<div class="tb-form-row">
<div class="fixed-title-width" [class.tb-required]="defaultValueRequired">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1 overflow-hidden" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.default-value-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
}
} @else {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.time-window' | translate }}</div>

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

@ -25,7 +25,7 @@ import {
ViewChild
} from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import {
ArgumentEntityType,
@ -34,7 +34,10 @@ import {
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentValue,
getCalculatedFieldCurrentEntityFilter
FORBIDDEN_NAMES,
forbiddenNamesValidator,
getCalculatedFieldCurrentEntityFilter,
uniqueNameValidator
} from '@shared/models/calculated-field.models';
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
@ -70,9 +73,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() usedArgumentNames: string[];
@Input() isOutputKey = false;
@Input() hiddenEntityTypes = false;
@Input() hiddenEntityKeyTypes = false;
@Input() hiddenDefaultValue = false;
@Input() defaultValueRequired = false;
@Input() hint: string;
@Input() predefinedEntityFilter: EntityFilter;
@Input() forbiddenNames = FORBIDDEN_NAMES;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
@ -85,7 +91,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10);
argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
argumentName: ['', [Validators.required, Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: [null],
refEntityKey: this.fb.group({
type: [ArgumentType.LatestTelemetry, [Validators.required]],
@ -140,6 +146,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
ngOnInit(): void {
this.updatedFormValidators();
this.updatedArgumentType();
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
@ -186,6 +193,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.popover.hide();
}
private updatedFormValidators(): void {
this.argumentFormGroup.get('argumentName').addValidators(
[uniqueNameValidator(this.usedArgumentNames), forbiddenNamesValidator(this.forbiddenNames)]);
this.argumentFormGroup.get('argumentName').updateValueAndValidity({emitEvent: false});
}
private updatedArgumentType(): void {
let argumentType = ArgumentEntityType.Current;
if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) {
@ -264,15 +277,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
});
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedArgumentNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private observeEntityKeyChanges(): void {
this.argumentFormGroup.get('refEntityKey').get('type').valueChanges
.pipe(takeUntilDestroyed())
@ -306,14 +310,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
}
private forbiddenArgumentNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx'];
return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void {
const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type);
this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']({emitEvent});

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

@ -29,6 +29,9 @@ import {
import {
RelatedAggregationArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component';
import {
EntityAggregationArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component';
@NgModule({
imports: [
@ -39,12 +42,14 @@ import {
CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
RelatedAggregationArgumentsTableComponent,
EntityAggregationArgumentsTableComponent,
],
exports: [
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
RelatedAggregationArgumentsTableComponent,
EntityAggregationArgumentsTableComponent,
]
})
export class CalculatedFieldArgumentsTableModule {}

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

@ -0,0 +1,71 @@
///
/// 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, 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 } from '@shared/models/calculated-field.models';
@Component({
selector: 'tb-entity-aggregation-arguments-table',
templateUrl: './calculated-field-arguments-table.component.html',
styleUrls: [`calculated-field-arguments-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent),
multi: true
}
],
})
export class EntityAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent {
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);
this.argumentNameColumn = 'calculated-fields.argument-name';
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
hiddenEntityTypes: true,
argumentEntityTypes: [ArgumentEntityType.Current],
hint: 'calculated-fields.entity-aggregation.argument-setting-hint',
hiddenDefaultValue: true,
hiddenEntityKeyTypes: true,
};
this.isScript = false;
}
}

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

@ -31,7 +31,12 @@ 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 {
ArgumentEntityType,
ArgumentType,
CalculatedFieldArgumentValue,
FORBIDDEN_NAMES
} from '@shared/models/calculated-field.models';
import { isDefined, isUndefinedOrNull } from '@core/utils';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@ -88,7 +93,8 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
argumentEntityTypes: [ArgumentEntityType.Current],
isOutputKey: true
isOutputKey: true,
forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'],
};
}
}

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

@ -90,6 +90,13 @@
[tenantId]="data.tenantId"
></tb-related-entities-aggregation-component>
}
@case (CalculatedFieldType.ENTITY_AGGREGATION) {
<tb-entity-aggregation-component formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId"
></tb-entity-aggregation-component>
}
@default {
<tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId"

133
ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html

@ -0,0 +1,133 @@
<!--
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]="entityAggregationConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title">
{{ 'calculated-fields.arguments' | translate }}
</div>
<div class="tb-form-hint tb-primary-fill hint-container">
{{ 'calculated-fields.entity-aggregation.argument-hint' | translate }}
</div>
<tb-entity-aggregation-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"/>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}">
{{ 'calculated-fields.metrics.metrics' | translate }}
</div>
<tb-calculated-field-metrics-table formControlName="metrics"
simpleMode
[arguments]="arguments$ | async"
></tb-calculated-field-metrics-table>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.aggregation-interval-hint' | translate }}">
{{ 'calculated-fields.entity-aggregation.aggregation-interval' | translate }}
</div>
<ng-container formGroupName="interval">
<div class="flex items-start gap-3 xs:flex-col xs:items-stretch">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>calculated-fields.aggregate-interval-type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of AggIntervalTypes" [value]="type">
{{ AggIntervalTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-timezone-select
class="flex-1"
appearance="outline"
subscriptSizing="dynamic"
required
userTimezoneByDefault
formControlName="tz">
</tb-timezone-select>
</div>
@if (entityAggregationConfiguration.get('interval.type').value === AggIntervalType.CUSTOM) {
<tb-time-unit-input required
[minTime]="minAllowedAggregationIntervalInSecForCF"
[stepMultipleOf]="DayInSec"
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
containerClass="flex gap-3"
labelText="{{ 'calculated-fields.aggregate-interval-value' | translate }}"
minErrorText="{{ 'calculated-fields.aggregate-interval-value-min' | translate : {sec: minAllowedAggregationIntervalInSecForCF} }}"
requiredText="{{ 'calculated-fields.aggregate-interval-value-required' | translate }}"
stepMultipleOfErrorText="{{ 'calculated-fields.aggregate-interval-value-step-multiple-of' | translate }}"
formControlName="durationSec">
</tb-time-unit-input>
}
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide flex" formControlName="allowOffsetSec">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.apply-offset-hint' | translate }}">
{{ 'calculated-fields.entity-aggregation.apply-offset' | translate }}
</div>
</mat-slide-toggle>
@if (entityAggregationConfiguration.get('interval.allowOffsetSec').value) {
<tb-time-unit-input required
[minTime]="0"
[maxTime]="maxOffsetTime"
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
containerClass="flex gap-3"
labelText="{{ 'calculated-fields.entity-aggregation.offset-value' | translate }}"
minErrorText="{{ 'calculated-fields.entity-aggregation.offset-value-min' | translate }}"
maxErrorText="{{ 'calculated-fields.entity-aggregation.offset-value-max' | translate }}"
requiredText="{{ 'calculated-fields.entity-aggregation.offset-value-required' | translate }}"
formControlName="offsetSec">
</tb-time-unit-input>
<div class="tb-form-hint tb-primary-fill hint-container">
{{ hint }}
</div>
}
</div>
</ng-container>
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide flex" formControlName="allowWatermark">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.wait-delay-hint' | translate }}">
{{ 'calculated-fields.entity-aggregation.wait-delay' | translate }}
</div>
</mat-slide-toggle>
@if (entityAggregationConfiguration.get('allowWatermark').value) {
<ng-container formGroupName="watermark">
<tb-time-unit-input required
[minTime]="60"
sameWidthInputs
appearance="outline"
containerClass="flex gap-3"
labelText="{{ 'calculated-fields.entity-aggregation.duration' | translate }}"
minErrorText="{{ 'calculated-fields.entity-aggregation.duration-min' | translate }}"
hintText="{{ 'calculated-fields.entity-aggregation.duration-hint' | translate }}"
requiredText="{{ 'calculated-fields.entity-aggregation.duration-required' | translate }}"
formControlName="duration">
</tb-time-unit-input>
</ng-container>
}
</div>
</div>
<tb-calculate-field-output formControlName="output"
containerInputClass="grid items-start grid-cols-2 gap-3 xs:grid-cols-1"
simpleMode
hiddenName
disableType
[entityId]="entityId"></tb-calculate-field-output>
</div>

411
ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts

@ -0,0 +1,411 @@
///
/// 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 {
AggInterval,
AggIntervalType,
AggIntervalTypeTranslations,
CalculatedFieldEntityAggregationConfiguration,
CalculatedFieldOutput,
CalculatedFieldType,
notEmptyObjectValidator,
OutputType
} from '@shared/models/calculated-field.models';
import { filter, map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, YEAR } from '@shared/models/time/time.models';
import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { merge } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import _moment from 'moment';
interface CalculatedFieldEntityAggregationConfigurationValue extends CalculatedFieldEntityAggregationConfiguration {
interval: AggInterval & {allowOffsetSec?: boolean};
allowWatermark: boolean;
}
enum TimeCategory {
SECONDS = 'SECONDS',
MINUTES = 'MINUTES',
HOURS = 'HOURS',
DAYS = 'DAYS'
}
@Component({
selector: 'tb-entity-aggregation-component',
templateUrl: './entity-aggregation-component.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EntityAggregationComponentComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => EntityAggregationComponentComponent),
multi: true
}
],
})
export class EntityAggregationComponentComponent implements ControlValueAccessor, Validator {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF;
readonly DayInSec = DAY / SECOND;
entityAggregationConfiguration = this.fb.group({
arguments: this.fb.control({}, notEmptyObjectValidator()),
metrics: this.fb.control({}, notEmptyObjectValidator()),
interval: this.fb.group({
type: [AggIntervalType.HOUR],
tz: ['', Validators.required],
durationSec: [this.minAllowedAggregationIntervalInSecForCF, Validators.required],
allowOffsetSec: [false],
offsetSec: [this.minAllowedAggregationIntervalInSecForCF > 60 ? MINUTE / SECOND : 1, Validators.required],
}),
allowWatermark: [false],
watermark: this.fb.group({
duration: [HOUR/SECOND, Validators.required],
}),
output: this.fb.control<CalculatedFieldOutput>({
type: OutputType.Timeseries,
}),
});
arguments$ = this.entityAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => Object.keys(argumentsObj))
);
AggIntervalType = AggIntervalType;
AggIntervalTypes = Object.values(AggIntervalType) as AggIntervalType[];
AggIntervalTypeTranslations = AggIntervalTypeTranslations;
hint: string;
private propagateChange: (config: CalculatedFieldEntityAggregationConfiguration) => void = () => { };
constructor(private fb: FormBuilder,
private store: Store<AppState>,
private translate: TranslateService,) {
this.entityAggregationConfiguration.get('interval.type').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((type: AggIntervalType) => {
this.checkAggIntervalType(type);
});
this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((allow: boolean) => {
this.checkIntervalDuration(allow);
});
this.entityAggregationConfiguration.get('allowWatermark').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((allow: boolean) => {
this.checkWatermark(allow);
});
merge(
this.entityAggregationConfiguration.get('interval.type').valueChanges,
this.entityAggregationConfiguration.get('interval.durationSec').valueChanges,
this.entityAggregationConfiguration.get('interval.offsetSec').valueChanges,
this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges,
).pipe(
filter(() => this.entityAggregationConfiguration.get('interval.allowOffsetSec').value),
takeUntilDestroyed()
).subscribe(() => {
this.updatedOffsetHint();
});
this.entityAggregationConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => {
this.updatedModel(deepClone(value));
});
}
validate(): ValidationErrors | null {
return this.entityAggregationConfiguration.valid || this.entityAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false};
}
writeValue(value: CalculatedFieldEntityAggregationConfiguration): void {
const data: CalculatedFieldEntityAggregationConfigurationValue = {
...value,
allowWatermark: isDefinedAndNotNull(value.watermark),
interval: {...value.interval, allowOffsetSec: isDefinedAndNotNull(value?.interval?.offsetSec)}
}
this.entityAggregationConfiguration.patchValue(data, {emitEvent: false});
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
this.updatedOffsetHint();
setTimeout(() => {
this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: CalculatedFieldEntityAggregationConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.entityAggregationConfiguration.disable({emitEvent: false});
} else {
this.entityAggregationConfiguration.enable({emitEvent: false});
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
}
}
get maxOffsetTime(): number {
switch (this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType) {
case AggIntervalType.HOUR:
return HOUR / SECOND - 1;
case AggIntervalType.DAY:
return DAY / SECOND - 1;
case AggIntervalType.WEEK:
case AggIntervalType.WEEK_SUN_SAT:
return 7 * DAY / SECOND - 1;
case AggIntervalType.MONTH:
return AVG_MONTH / SECOND;
case AggIntervalType.QUARTER:
return AVG_QUARTER / SECOND - 1;
case AggIntervalType.YEAR:
return YEAR / SECOND - 1;
case AggIntervalType.CUSTOM:
return this.entityAggregationConfiguration.get('interval.durationSec').value - 1;
}
}
private updatedModel(value: CalculatedFieldEntityAggregationConfigurationValue): void {
value.type = CalculatedFieldType.ENTITY_AGGREGATION;
if (!value.interval.allowOffsetSec) {
delete value.interval.offsetSec;
}
delete value.interval.allowOffsetSec;
if (!value.allowWatermark) {
delete value.watermark;
}
delete value.allowWatermark;
this.propagateChange(value);
}
private checkAggIntervalType(type: AggIntervalType) {
if (type === AggIntervalType.CUSTOM) {
this.entityAggregationConfiguration.get('interval.durationSec').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('interval.durationSec').disable({emitEvent: false});
}
}
private checkIntervalDuration(allow: boolean) {
if (allow) {
this.entityAggregationConfiguration.get('interval.offsetSec').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('interval.offsetSec').disable({emitEvent: false});
this.hint = '';
}
}
private checkWatermark(allow: boolean) {
if (allow) {
this.entityAggregationConfiguration.get('watermark').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('watermark').disable({emitEvent: false});
}
}
private updatedOffsetHint(): void {
const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value;
const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType;
const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value;
const offsetCategory = this.getTimeCategory(offset);
const now = _moment.utc();
let interval: string = '';
if (intervalType === AggIntervalType.CUSTOM) {
const durationSecCategory = this.getTimeCategory(durationSec);
const formatString = this.getCustomFormatString(offsetCategory, durationSecCategory);
const intervals: string[] = [];
let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND;
now.startOf('year').add(offset, 'seconds');
let repeat = 2;
if (allInterval) {
repeat = Math.floor(DAY/SECOND/durationSec);
if (repeat > 4) {
repeat = 2;
allInterval = false;
}
}
for (let i = 0; i < repeat; i++) {
const s1 = now.clone().add(i * durationSec, 'seconds').format(formatString);
const s2 = now.clone().add((i + 1) * durationSec, 'seconds').format(formatString);
intervals.push(`${s1} - ${s2}`);
}
interval = intervals.join('; ');
if (allInterval) {
this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset', {interval});
} else {
interval += '…'
this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', {interval});
}
} else {
interval = this.buildStandardIntervalString(now, intervalType, offset, offsetCategory);
this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', { interval });
}
}
private getTimeCategory(seconds: number): TimeCategory {
if (seconds % (DAY / SECOND) === 0) {
return TimeCategory.DAYS;
}
if (seconds % (HOUR / SECOND) === 0) {
return TimeCategory.HOURS;
}
if (seconds % (MINUTE / SECOND) === 0) {
return TimeCategory.MINUTES;
}
return TimeCategory.SECONDS;
}
private getCustomFormatString(offsetCat: TimeCategory, durationCat: TimeCategory): string {
if (durationCat === TimeCategory.DAYS) {
if (offsetCat === TimeCategory.SECONDS) {
return '[Day] D, HH:mm:ss';
}
if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) {
return '[Day] D, HH:mm';
}
return '[Day] D';
} else {
if (offsetCat === TimeCategory.SECONDS) {
return 'HH:mm:ss';
}
return 'HH:mm';
}
}
private formatAdditiveInterval(now: _moment.Moment, addUnit: 'hour' | 'day' | 'month' | 'quarter', offsetCat: TimeCategory,
formats: { [key in TimeCategory]?: { s1: string, s2: string, s3: string } }): string {
const formatTs = formats[offsetCat] || formats[TimeCategory.SECONDS];
if (!formatTs) {
return '';
}
const s1 = now.format(formatTs.s1);
const s2 = now.clone().add(1, addUnit).format(formatTs.s2);
const s3 = now.clone().add(2, addUnit).format(formatTs.s3);
return `${s1} - ${s2}; ${s2} - ${s3}…`;
}
private formatNextInterval(now: _moment.Moment, offsetCat: TimeCategory, secFmt: string, minHourFmt: string, dayFmt: string): string {
let s1: string;
if (offsetCat === TimeCategory.SECONDS) {
s1 = now.format(secFmt);
} else if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) {
s1 = now.format(minHourFmt);
} else {
s1 = now.format(dayFmt);
}
const s2 = `Next ${s1}`;
const s3 = `Following ${s1}`;
return `${s1} - ${s2}; ${s2} - ${s3}… `;
}
private buildStandardIntervalString(now: _moment.Moment, type: AggIntervalType, offset: number, offsetCat: TimeCategory): string {
switch (type) {
case AggIntervalType.HOUR:
now.startOf('day').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'hour', offsetCat, {
[TimeCategory.SECONDS]: { s1: 'HH:mm:ss', s2: 'HH:mm:ss', s3: 'HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: 'HH:mm:ss', s2: 'HH:mm', s3: 'HH:mm' }
});
case AggIntervalType.DAY:
now.startOf('month').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'day', offsetCat, {
[TimeCategory.SECONDS]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm:ss', s3: '[Day] D, HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' },
[TimeCategory.HOURS]: { s1: 'HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' } // Note: Original logic, s1 format is different
});
case AggIntervalType.WEEK:
now.isoWeekday(1).startOf('isoWeek').add(offset, 'seconds');
return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd');
case AggIntervalType.WEEK_SUN_SAT:
now.startOf('week').add(offset, 'seconds');
return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd');
case AggIntervalType.MONTH:
now.startOf('year').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'month', offsetCat, {
[TimeCategory.SECONDS]: { s1: 'Do [of month], HH:mm:ss', s2: '[Next] Do, HH:mm:ss', s3: '[Following] Do, HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' },
[TimeCategory.HOURS]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' },
[TimeCategory.DAYS]: { s1: 'Do [of month]', s2: '[Next] Do', s3: '[Following] Do' }
});
case AggIntervalType.QUARTER:
now.startOf('year').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'quarter', offsetCat, {
[TimeCategory.SECONDS]: { s1: 'MMM Do, HH:mm:ss', s2: 'MMM Do, HH:mm:ss', s3: 'MMM Do, HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' },
[TimeCategory.HOURS]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' },
[TimeCategory.DAYS]: { s1: 'MMM Do', s2: 'MMM Do', s3: 'MMM Do' }
});
case AggIntervalType.YEAR:
now.startOf('year').add(offset, 'seconds');
return this.formatNextInterval(now, offsetCat, 'MMM Do, HH:mm:ss', 'MMM Do, HH:mm', 'MMM Do');
default:
return '';
}
}
}

49
ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts

@ -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.
///
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 {
EntityAggregationComponentComponent
} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component';
import {
CalculatedFieldMetricsTableModule
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
CalculatedFieldMetricsTableModule,
],
declarations: [
EntityAggregationComponentComponent,
],
exports: [
EntityAggregationComponentComponent,
]
})
export class EntityAggregationComponentModule {
}

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

@ -32,11 +32,14 @@ import {
ArgumentEntityTypeTranslations,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingValue,
FORBIDDEN_NAMES,
forbiddenNamesValidator,
GeofencingDirectionLevelTranslations,
GeofencingDirectionTranslations,
GeofencingReportStrategy,
GeofencingReportStrategyTranslations,
getCalculatedFieldCurrentEntityFilter
getCalculatedFieldCurrentEntityFilter,
uniqueNameValidator
} from '@shared/models/calculated-field.models';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
@ -76,7 +79,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
readonly maxRelationLevelPerCfArgument = getCurrentAuthState(this.store).maxRelationLevelPerCfArgument;
geofencingFormGroup = this.fb.group({
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({
entityType: [ArgumentEntityType.Current],
id: ['']
@ -133,6 +136,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
}
ngOnInit(): void {
this.updatedFormValidators();
this.geofencingFormGroup.patchValue(this.zone, {emitEvent: false});
if (this.zone.refDynamicSourceConfiguration?.type) {
this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false});
@ -159,6 +163,11 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search))));
}
private updatedFormValidators(): void {
this.geofencingFormGroup.get('name').addValidators(uniqueNameValidator(this.usedNames));
this.geofencingFormGroup.get('name').updateValueAndValidity({emitEvent: false});
}
private observeCreateRelationZonesChanges(): void {
this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges
.pipe(takeUntilDestroyed())
@ -275,29 +284,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
});
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private levelsRequired(): ValidatorFn {
return (control: FormControl) => {
return control.value.length ? null : { levelsRequired: true };
};
}
private forbiddenNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenNames = ['ctx', 'e', 'pi'];
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
levelsFormArray(): UntypedFormArray {
return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray;
}

92
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html

@ -77,48 +77,52 @@
</mat-form-field>
</div>
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [(expanded)]="filterExpanded"
[disabled]="!metricForm.get('allowFilter').value">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="allowFilter"
(click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.filter-hint' | translate }}">
{{ 'calculated-fields.metrics.filter' | translate }}
@if (!simpleMode) {
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [(expanded)]="filterExpanded"
[disabled]="!metricForm.get('allowFilter').value">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="allowFilter"
(click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.filter-hint' | translate }}">
{{ 'calculated-fields.metrics.filter' | translate }}
</div>
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-js-func required
formControlName="filter"
functionName="filter"
[functionArgs]="functionArgs"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="highlightRules"
[editorCompleter]="editorCompleter"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/filter_expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-js-func required
formControlName="filter"
functionName="filter"
[functionArgs]="functionArgs"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="highlightRules"
[editorCompleter]="editorCompleter"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/filter_expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
</tb-js-func>
</ng-template>
</mat-expansion-panel>
</div>
<ng-container formGroupName="input">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (inputType of AggInputTypes; track inputType) {
<mat-option [value]="inputType">{{ AggInputTypeTranslations.get(inputType) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</tb-js-func>
</ng-template>
</mat-expansion-panel>
</div>
}
<ng-container formGroupName="input">
@if (!simpleMode) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (inputType of AggInputTypes; track inputType) {
<mat-option [value]="inputType">{{ AggInputTypeTranslations.get(inputType) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@if (this.metricForm.get('input.type').value === AggInputType.key) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div>
@ -156,6 +160,14 @@
</tb-js-func>
}
</ng-container>
@if (simpleMode) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" type="number" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
</mat-form-field>
</div>
}
</div>
<div class="tb-config-panel-buttons">
<button mat-button

41
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts

@ -16,14 +16,17 @@
import { Component, Input, OnInit, output } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, ValidatorFn, Validators } from '@angular/forms';
import { FormBuilder, Validators } from '@angular/forms';
import { charsWithNumRegex } from '@shared/models/regex.constants';
import {
AggFunction,
AggFunctionTranslations,
AggInputType,
AggInputTypeTranslations,
CalculatedFieldAggMetricValue
CalculatedFieldAggMetricValue,
FORBIDDEN_NAMES,
forbiddenNamesValidator,
uniqueNameValidator
} from '@shared/models/calculated-field.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models';
@ -46,6 +49,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
@Input() metric: CalculatedFieldAggMetricValue;
@Input() usedNames: string[];
@Input() arguments: Array<string>;
@Input() simpleMode: boolean;
@Input() editorCompleter: TbEditorCompleter;
@Input() highlightRules: AceHighlightRules;
@ -54,7 +58,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
functionArgs: Array<string>
metricForm = this.fb.group({
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
function: [AggFunction.AVG],
allowFilter: [false],
filter: ['', Validators.required],
@ -62,7 +66,8 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
type: [AggInputType.key],
key: ['', Validators.required],
function: ['', Validators.required],
})
}),
defaultValue: [null]
});
entityFilter: EntityFilter;
@ -83,6 +88,8 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
}
ngOnInit(): void {
this.updatedForm();
const data: CalculatedFieldAggMetricValuePanel = {
...this.metric,
allowFilter: !!this.metric.filter,
@ -109,6 +116,15 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
this.popover.hide();
}
private updatedForm(): void {
this.metricForm.get('name').addValidators(uniqueNameValidator(this.usedNames));
this.metricForm.get('name').updateValueAndValidity({emitEvent: false});
if (!this.simpleMode) {
this.metricForm.removeControl('defaultValue', {emitEvent: false});
}
}
private observeFilterAllowChange(): void {
this.metricForm.get('allowFilter').valueChanges
.pipe(takeUntilDestroyed())
@ -147,21 +163,4 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
this.metricForm.get('input.key').markAsTouched();
}
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private forbiddenNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenNames = ['ctx', 'e', 'pi'];
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
}

20
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html

@ -20,10 +20,16 @@
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/5 xs:!w-full sm:!w-1/2">
<mat-header-cell mat-sort-header *matHeaderCellDef class="xs:!w-full sm:!w-1/2" [class]="{
'!w-1/5': !simpleMode,
'!w-1/2': simpleMode,
}">
<div tbTruncateWithTooltip>{{ 'calculated-fields.metrics.metric-name' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let metric" class="argument-name-cell w-1/5 xs:w-full sm:w-1/2">
<mat-cell *matCellDef="let metric" class="argument-name-cell xs:w-full sm:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
<div class="flex items-center">
<div tbTruncateWithTooltip class="flex-1">{{ metric.name }}</div>
<tb-copy-button class="copy-argument-name"
@ -35,10 +41,16 @@
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'function'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 xs:hidden lt-md:w-1/2">
<mat-header-cell mat-sort-header *matHeaderCellDef class="xs:hidden lt-md:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
{{ 'calculated-fields.metrics.aggregation' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let metric" class="w-1/5 xs:hidden lt-md:w-1/2">
<mat-cell *matCellDef="let metric" class="xs:hidden lt-md:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
<div tbTruncateWithTooltip>{{ AggFunctionTranslations.get(metric.function) | translate }}</div>
</mat-cell>
</ng-container>

18
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts

@ -16,11 +16,13 @@
import {
AfterViewInit,
booleanAttribute,
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
Input,
OnInit,
Renderer2,
ViewChild,
ViewContainerRef,
@ -51,7 +53,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
CalculatedFieldMetricsPanelComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component';
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component';
import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { AceHighlightRules } from '@shared/models/ace/ace.models';
@ -72,11 +74,12 @@ import { AceHighlightRules } from '@shared/models/ace/ace.models';
}
],
})
export class CalculatedFieldMetricsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValueAccessor, Validator, AfterViewInit {
@Input() arguments: Array<string>;
@Input() editorCompleter: TbEditorCompleter;
@Input() highlightRules: AceHighlightRules;
@Input({transform: booleanAttribute}) simpleMode: boolean = false;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@ -85,7 +88,7 @@ export class CalculatedFieldMetricsTableComponent implements ControlValueAccesso
sortOrder = { direction: 'asc' as SortDirection, property: '' };
dataSource = new CalculatedFieldMetricsDatasource();
displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions']
displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions'];
readonly AggFunctionTranslations = AggFunctionTranslations;
readonly AggInputTypeTranslations = AggInputTypeTranslations;
@ -109,6 +112,12 @@ export class CalculatedFieldMetricsTableComponent implements ControlValueAccesso
});
}
ngOnInit() {
if (this.simpleMode) {
this.displayColumns = ['name', 'function', 'actions'];
}
}
ngAfterViewInit(): void {
this.sort.sortChange.asObservable().pipe(
takeUntilDestroyed(this.destroyRef)
@ -155,7 +164,8 @@ export class CalculatedFieldMetricsTableComponent implements ControlValueAccesso
usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name),
arguments: this.arguments,
editorCompleter: this.editorCompleter,
highlightRules: this.highlightRules
highlightRules: this.highlightRules,
simpleMode: this.simpleMode,
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,

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

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

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

@ -17,7 +17,7 @@
-->
<div class="tb-form-panel" [formGroup]="outputForm">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex flex-col gap-3">
<div [class]=containerInputClass>
<div class="flex gap-3 xs:flex-col">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
@ -44,7 +44,7 @@
</div>
@if (simpleMode) {
@if (hiddenName) {
<div class="grid grid-cols-2 items-start gap-3 xs:grid-cols-1">
<div class="grid items-start gap-3 has-[.simpleMode]:grid-cols-2 xs:grid-cols-1">
<ng-container *ngTemplateOutlet="decimalsByDefaultField"></ng-container>
<ng-content select=".simpleMode"></ng-content>
</div>

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

@ -63,6 +63,13 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
@coerceBoolean()
hiddenName = false;
@Input()
@coerceBoolean()
disableType = false;
@Input()
containerInputClass: string | string[] | Record<string, boolean | undefined | null> = 'flex flex-col gap-3';
@Input({required: true})
entityId: EntityId;
@ -141,6 +148,9 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
if (this.simpleMode && 'name' in value) {
value.name = value.name?.trim() ?? '';
}
if (this.disableType) {
value.type = this.outputForm.get('type').value;
}
this.propagateChange(value);
}
@ -163,5 +173,8 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
} else {
this.outputForm.get('decimalsByDefault').disable({emitEvent: false});
}
if (this.disableType) {
this.outputForm.get('type').disable({emitEvent: false});
}
}
}

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

@ -60,6 +60,7 @@
[editorCompleter]="argumentsEditorCompleter$ | async"
></tb-calculated-field-metrics-table>
<tb-time-unit-input required
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
labelText="{{ 'calculated-fields.deduplication-interval' | translate }}"

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

@ -27,11 +27,8 @@ import {
RelatedEntitiesAggregationComponentComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component';
import {
CalculatedFieldMetricsTableComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component';
import {
CalculatedFieldMetricsPanelComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component';
CalculatedFieldMetricsTableModule
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module';
@NgModule({
imports: [
@ -39,11 +36,10 @@ import {
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
CalculatedFieldMetricsTableModule,
],
declarations: [
RelatedEntitiesAggregationComponentComponent,
CalculatedFieldMetricsTableComponent,
CalculatedFieldMetricsPanelComponent
],
exports: [
RelatedEntitiesAggregationComponentComponent,

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

@ -342,17 +342,17 @@
</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"
<mat-label translate>tenant-profile.min-allowed-aggregation-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="minAllowedAggregationIntervalInSecForCF"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('required')">
{{ 'tenant-profile.relation-search-entity-limit-required' | translate}}
<mat-error *ngIf="tenantProfileConfigurationForm.get('minAllowedAggregationIntervalInSecForCF').hasError('required')">
{{ 'tenant-profile.min-allowed-aggregation-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('min')">
{{ 'tenant-profile.relation-search-entity-limit-range' | translate}}
<mat-error *ngIf="tenantProfileConfigurationForm.get('minAllowedAggregationIntervalInSecForCF').hasError('min')">
{{ 'tenant-profile.min-allowed-aggregation-interval-range' | translate}}
</mat-error>
<mat-hint translate>tenant-profile.relation-search-entity-limit-hint</mat-hint>
<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-deduplication-interval</mat-label>
@ -368,6 +368,22 @@
<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>

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

@ -117,6 +117,7 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA
maxArgumentsPerCF: [0, [Validators.required, Validators.min(0)]],
maxRelationLevelPerCfArgument: [1, [Validators.required, Validators.min(1)]],
minAllowedDeduplicationIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]],
minAllowedAggregationIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]],
maxRelatedEntitiesToReturnPerCfArgument: [1, [Validators.required, Validators.min(1)]],
minAllowedScheduledUpdateIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]],
maxDataPointsPerRollingArg: [0, [Validators.required, Validators.min(0)]],

8
ui-ngx/src/app/shared/components/time-unit-input.component.html

@ -15,8 +15,8 @@
limitations under the License.
-->
<section [formGroup]="timeInputForm" class="flex gap-4">
<mat-form-field [class]="{'number': inlineField, 'max-w-66%': !inlineField, 'flex-full': !inlineField}"
<section [formGroup]="timeInputForm" [class]="containerClass">
<mat-form-field [class]="{'number': inlineField, 'max-w-66%': !inlineField && !sameWidthInputs, 'flex-full': !inlineField}"
[appearance]="inlineField ? 'outline' : appearance"
subscriptSizing="dynamic">
@if (labelText && !inlineField) {
@ -36,13 +36,13 @@
warning
</mat-icon>
}
<mat-hint *ngIf="(subscriptSizing === 'fixed') && !inlineField"></mat-hint>
<mat-hint *ngIf="(subscriptSizing === 'fixed') && !inlineField">{{ hintText }}</mat-hint>
<mat-error *ngIf="hasError && !inlineField">
{{ hasError }}
</mat-error>
</mat-form-field>
<mat-form-field [class.h-fit]="!inlineField"
[class.max-w-33%]="!inlineField"
[class.max-w-33%]="!inlineField && !sameWidthInputs"
[class.flex-full]="!inlineField"
[appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">

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

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import { Component, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
@ -23,6 +23,7 @@ import {
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
ValidatorFn,
Validators
} from '@angular/forms';
import { TimeUnit, timeUnitTranslations } from '@home/components/rule-node/rule-node-config.models';
@ -50,11 +51,14 @@ interface TimeUnitInputModel {
multi: true
}]
})
export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit {
export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges {
@Input()
labelText: string;
@Input()
hintText: string;
@Input()
@coerceBoolean()
required: boolean;
@ -76,6 +80,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
@Input()
maxErrorText: string;
@Input()
@coerceNumber()
stepMultipleOf: number;
@Input()
stepMultipleOfErrorText: string;
@Input()
subscriptSizing: SubscriptSizing = 'fixed';
@ -86,6 +97,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
@coerceBoolean()
inlineField: boolean;
@Input()
@coerceBoolean()
sameWidthInputs: boolean = false;
@Input()
containerClass: string | string[] | Record<string, boolean | undefined | null> = "flex gap-4";
timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
timeUnitTranslations = timeUnitTranslations;
@ -111,17 +129,10 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
}
ngOnInit() {
if (this.maxTime) {
const maxTimeMs = this.maxTime * SECOND;
if (maxTimeMs < MINUTE) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (maxTimeMs < HOUR) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (maxTimeMs < DAY) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS);
}
if (isDefinedAndNotNull(this.maxTime)) {
this.updatedAllowTimeUnitInterval(this.maxTime);
}
if(this.required || this.maxTime) {
if (this.required || this.maxTime || isDefinedAndNotNull(this.minTime) || this.stepMultipleOf) {
const timeControl = this.timeInputForm.get('time');
const validators = [Validators.pattern(/^\d*$/)];
if (this.required) {
@ -138,6 +149,10 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
);
}
if (isDefinedAndNotNull(this.stepMultipleOf) && this.stepMultipleOf > 0) {
validators.push(this.createStepMultipleOfValidator());
}
timeControl.setValidators(validators);
timeControl.updateValueAndValidity({ emitEvent: false });
}
@ -163,6 +178,23 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
return this.minErrorText;
} else if (this.timeInputForm.get('time').hasError('max') && this.maxErrorText) {
return this.maxErrorText;
} else if (this.timeInputForm.get('time').hasError('stepMultipleOf') && this.stepMultipleOfErrorText) {
return this.stepMultipleOfErrorText;
}
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'maxTime') {
if (isDefinedAndNotNull(this.maxTime)) {
this.timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
this.updatedAllowTimeUnitInterval(this.maxTime);
this.timeInputForm.get('time').updateValueAndValidity({emitEvent: false});
}
}
}
}
}
@ -225,4 +257,46 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
}
}
private createStepMultipleOfValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const time = control.value;
if (!isDefinedAndNotNull(time) || !isNumeric(time)) {
return null;
}
const numericTime = Number(time);
if (numericTime === 0) {
return null;
}
const timeUnit = control.parent?.get('timeUnit')?.value as TimeUnit;
if (!timeUnit) {
return null;
}
const unitInSec = this.timeIntervalsInSec.get(timeUnit);
const totalTimeInSec = numericTime * unitInSec;
const multipleOfVal = this.stepMultipleOf;
let isValid: boolean;
if (totalTimeInSec < multipleOfVal) {
isValid = (multipleOfVal % totalTimeInSec === 0);
} else {
isValid = (totalTimeInSec % multipleOfVal === 0);
}
return isValid ? null : { stepMultipleOf: true };
};
}
private updatedAllowTimeUnitInterval(maxTime: number) {
const maxTimeMs = maxTime * SECOND;
this.timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
if (maxTimeMs < MINUTE) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (maxTimeMs < HOUR) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
} else if (maxTimeMs < DAY) {
this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS);
}
}
}

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

@ -30,10 +30,12 @@ import {
endGroupHighlightRule
} from '@shared/models/ace/ace.models';
import { EntitySearchDirection } from '@shared/models/relation.models';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { AlarmRule } from "@shared/models/alarm-rule.models";
import { AlarmSeverity } from "@shared/models/alarm.models";
export const FORBIDDEN_NAMES = ['ctx', 'e', 'pi'];
interface BaseCalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
entityId: EntityId;
}
@ -76,6 +78,7 @@ export enum CalculatedFieldType {
GEOFENCING = 'GEOFENCING',
PROPAGATION = 'PROPAGATION',
RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION',
ENTITY_AGGREGATION = 'ENTITY_AGGREGATION',
ALARM = 'ALARM',
}
@ -102,6 +105,10 @@ export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, Calc
name: 'calculated-fields.type.related-entities-aggregation',
hint: 'calculated-fields.type.related-entities-aggregation-hint'
}],
[CalculatedFieldType.ENTITY_AGGREGATION, {
name: 'calculated-fields.type.time-series-data-aggregation',
hint: 'calculated-fields.type.time-series-data-aggregation-hint',
}],
]
)
@ -111,6 +118,7 @@ export type CalculatedFieldConfiguration =
| CalculatedFieldGeofencingConfiguration
| CalculatedFieldPropagationConfiguration
| CalculatedFieldRelatedAggregationConfiguration
| CalculatedFieldEntityAggregationConfiguration
| CalculatedFieldAlarmRuleConfiguration;
export interface CalculatedFieldSimpleConfiguration {
@ -147,6 +155,19 @@ export interface CalculatedFieldRelatedAggregationConfiguration {
output: CalculatedFieldOutput & { decimalsByDefault?: number; };
}
export interface CalculatedFieldEntityAggregationConfiguration {
type: CalculatedFieldType.ENTITY_AGGREGATION;
arguments: Record<string, CalculatedFieldArgument>;
metrics: Record<string, CalculatedFieldAggMetric>;
interval: AggInterval;
watermark?: WatermarkConfig;
output: CalculatedFieldOutput & { decimalsByDefault?: number; };
}
export interface WatermarkConfig {
duration: number;
}
interface BasePropagationConfiguration {
type: CalculatedFieldType.PROPAGATION;
relation: RelationPathLevel;
@ -323,10 +344,42 @@ export const AggFunctionTranslations = new Map<AggFunction, string>([
[AggFunction.COUNT_UNIQUE, 'calculated-fields.metrics.aggregation-type.count-unique'],
])
export enum AggIntervalType {
HOUR = 'HOUR',
DAY = 'DAY',
WEEK = 'WEEK',
WEEK_SUN_SAT = 'WEEK_SUN_SAT',
MONTH = 'MONTH',
QUARTER = 'QUARTER',
YEAR = 'YEAR',
CUSTOM = 'CUSTOM'
}
export const AggIntervalTypeTranslations = new Map<AggIntervalType, string>(
[
[AggIntervalType.HOUR, 'calculated-fields.aggregate-period.hour'],
[AggIntervalType.DAY, 'calculated-fields.aggregate-period.day'],
[AggIntervalType.WEEK, 'calculated-fields.aggregate-period.week'],
[AggIntervalType.WEEK_SUN_SAT, 'calculated-fields.aggregate-period.week-sun-sat'],
[AggIntervalType.MONTH, 'calculated-fields.aggregate-period.month'],
[AggIntervalType.QUARTER, 'calculated-fields.aggregate-period.quarter'],
[AggIntervalType.YEAR, 'calculated-fields.aggregate-period.year'],
[AggIntervalType.CUSTOM, 'calculated-fields.aggregate-period.custom']
]
);
export interface AggInterval {
type: AggIntervalType;
tz: string;
offsetSec?: number
durationSec?: number
}
export interface CalculatedFieldAggMetric {
function: AggFunction;
filter?: string;
input: AggKeyInput | AggFunctionInput;
defaultValue?: number;
}
export interface CalculatedFieldAggMetricValue extends CalculatedFieldAggMetric {
@ -911,3 +964,26 @@ export function notEmptyObjectValidator(): ValidatorFn {
return null;
};
}
export function forbiddenNamesValidator(forbiddenNames: string[]): ValidatorFn {
const forbiddenNameSet = new Set(forbiddenNames);
return (control: FormControl) => {
const trimmedValue = (control.value || '').trim();
return forbiddenNameSet.has(trimmedValue) ? { forbiddenName: true } : null;
};
}
export function uniqueNameValidator(existingNames: string[]): ValidatorFn {
const namesSet = new Set((existingNames || []).map(name => name.toLowerCase()));
return (control: FormControl) => {
const newName = (control.value || '').trim().toLowerCase();
if (!newName) {
return null;
}
return namesSet.has(newName) ? { duplicateName: true } : null;
};
}

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

@ -108,6 +108,7 @@ export interface DefaultTenantProfileConfiguration {
maxArgumentsPerCF: number;
maxRelationLevelPerCfArgument: number;
minAllowedDeduplicationIntervalInSecForCF: number;
minAllowedAggregationIntervalInSecForCF: number;
maxRelatedEntitiesToReturnPerCfArgument: number;
minAllowedScheduledUpdateIntervalInSecForCF: number;
maxDataPointsPerRollingArg: number;
@ -176,6 +177,7 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan
maxDataPointsPerRollingArg: 1000,
maxRelationLevelPerCfArgument: 10,
minAllowedDeduplicationIntervalInSecForCF: 60,
minAllowedAggregationIntervalInSecForCF: 60,
maxRelatedEntitiesToReturnPerCfArgument: 100,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
maxStateSizeInKBytes: 32,

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

@ -1058,7 +1058,9 @@
"geofencing" : "Geofencing",
"propagation": "Propagation",
"related-entities-aggregation": "Related entities aggregation",
"related-entities-aggregation-hint": "Aggregation of data from related entities"
"related-entities-aggregation-hint": "Aggregation of data from related entities",
"time-series-data-aggregation": "Time series data aggregation",
"time-series-data-aggregation-hint": "Aggregation of historical data from a current entity"
},
"arguments": "Arguments",
"decimals-by-default": "Decimals by default",
@ -1193,6 +1195,41 @@
"filter": "Filter",
"filter-hint": "Enables filtering of entities during aggregation. The filter function must return a boolean value and can use all configured arguments."
},
"aggregate-interval-type": "Aggregate interval type",
"aggregate-interval-value": "Aggregate interval value",
"aggregate-interval-value-required": "Aggregate interval value is required",
"aggregate-interval-value-min": "Aggregate interval value should be at least { sec, plural, =0 {0 second} =1 {1 second} other {# seconds} }",
"aggregate-interval-value-step-multiple-of": "Aggregate interval value must be a divisor or multiple of 1 day",
"aggregate-period": {
"hour": "Hour",
"day": "Day",
"week": "Week (Mon - Sun)",
"week-sun-sat": "Week (Sun - Sat)",
"month": "Month",
"quarter": "Quarter",
"year": "Year",
"custom": "Custom"
},
"aggregate-period-hint-offset": "Your aggregation interval will be: {{ interval }}",
"aggregate-period-hint-offset-and-so-on": "Your aggregation interval will be: {{ interval }} and so on",
"entity-aggregation": {
"argument-hint": "Data will be fetched from selected entity",
"argument-setting-hint": "Latest telemetry is the only available argument type for this calculated field",
"aggregation-interval": "Aggregation interval",
"aggregation-interval-hint": "Defines how often to perform aggregation. Example: every 1 hour aggregates data at 00:00, 01:00, 02:00, etc.",
"apply-offset": "Apply offset to aggregation interval",
"apply-offset-hint": "Defines how much to shift the start of each aggregation period (e.g., +10 minutes - 00:10, 01:10).",
"offset-value": "Offset value",
"offset-value-required": "Offset value is required",
"offset-value-min": "Offset value must be a positive integer",
"offset-value-max": "Offset value should be less than the aggregate interval value",
"wait-delay": "Wait for delayed telemetry",
"wait-delay-hint": "Waits for delayed telemetry after the interval ends",
"duration": "Duration",
"duration-required": "Duration is required",
"duration-min": "Duration should be at least 1 minute",
"duration-hint": "How long to wait for delayed data after the interval ends"
},
"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.",
@ -6101,6 +6138,9 @@
"min-allowed-deduplication-interval": "Min allowed deduplication interval (seconds)",
"min-allowed-deduplication-interval-range": "Min allowed deduplication interval value can't be negative",
"min-allowed-deduplication-interval-required": "Min allowed deduplication interval is required",
"min-allowed-aggregation-interval": "Min allowed aggregation interval (seconds)",
"min-allowed-aggregation-interval-range": "Min allowed aggregation interval value can't be negative",
"min-allowed-aggregation-interval-required": "Min allowed aggregation interval is required",
"min-allowed-scheduled-update-interval-required": "Min allowed update interval min number is required",
"max-state-size": "State maximum size in KB",
"max-state-size-range": "State maximum size in KB can't be negative",

Loading…
Cancel
Save