Browse Source

Merge branch 'master' into feature/entity-limit-request

pull/14551/head
Igor Kulikov 10 months ago
parent
commit
6861acb8d3
  1. 12
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 8
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  3. 26
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  4. 1
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  5. 9
      application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  7. 56
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  8. 28
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  9. 22
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/HasLatestTs.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java
  11. 9
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java
  12. 18
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java
  13. 14
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java
  14. 14
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  15. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
  16. 11
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
  17. 1
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  18. 5
      application/src/main/resources/thingsboard.yml
  19. 62
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  20. 85
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  21. 36
      application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
  22. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  23. 1
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java
  24. 25
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  25. 2
      common/queue/pom.xml
  26. 4
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  27. 289
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java
  28. 2
      msa/js-executor/package.json
  29. 14
      msa/js-executor/yarn.lock
  30. 2
      msa/web-ui/package.json
  31. 14
      msa/web-ui/yarn.lock
  32. 14
      pom.xml
  33. 42
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  34. 2
      rule-engine/rule-engine-components/pom.xml
  35. 4
      tools/pom.xml
  36. 66
      ui-ngx/patches/@angular+common+18.2.13.patch
  37. 88
      ui-ngx/patches/@angular+compiler+18.2.13.patch
  38. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  39. 1
      ui-ngx/src/app/core/auth/auth.reducer.ts
  40. 60
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html
  41. 62
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts
  42. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  43. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html
  44. 45
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts
  45. 9
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html
  46. 30
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts
  47. 50
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html
  48. 14
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html
  49. 33
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts
  50. 115
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html
  51. 42
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts
  52. 92
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html
  53. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts
  54. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts
  55. 27
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html
  56. 60
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts
  57. 1
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html
  58. 3
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss
  59. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts
  60. 22
      ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html
  61. 33
      ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts
  62. 14
      ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts
  63. 18
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html
  64. 5
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts
  65. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  66. 9
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
  67. 54
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
  68. 12
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html
  69. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts
  70. 15
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html
  71. 16
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
  72. 1
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html
  73. 44
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html
  74. 3
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts
  75. 16
      ui-ngx/src/app/modules/home/components/relation/relation-table.component.html
  76. 11
      ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss
  77. 11
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html
  78. 6
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss
  79. 8
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts
  80. 4
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  81. 4
      ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts
  82. 8
      ui-ngx/src/app/shared/components/string-autocomplete.component.html
  83. 20
      ui-ngx/src/app/shared/components/string-autocomplete.component.scss
  84. 50
      ui-ngx/src/app/shared/components/string-autocomplete.component.ts
  85. 4
      ui-ngx/src/app/shared/import-export/import-export.service.ts
  86. 70
      ui-ngx/src/app/shared/models/alarm-rule.models.ts
  87. 1
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  88. 9
      ui-ngx/src/app/shared/models/tenant.model.ts
  89. 2
      ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md
  90. 43
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -24,8 +24,11 @@ SET profile_data = jsonb_set(
'minAllowedScheduledUpdateIntervalInSecForCF', 60, 'minAllowedScheduledUpdateIntervalInSecForCF', 60,
'maxRelationLevelPerCfArgument', 10, 'maxRelationLevelPerCfArgument', 10,
'maxRelatedEntitiesToReturnPerCfArgument', 100, 'maxRelatedEntitiesToReturnPerCfArgument', 100,
'minAllowedDeduplicationIntervalInSecForCF', 60, 'minAllowedDeduplicationIntervalInSecForCF', 10,
'minAllowedAggregationIntervalInSecForCF', 60 'minAllowedAggregationIntervalInSecForCF', 60,
'intermediateAggregationIntervalInSecForCF', 300,
'cfReevaluationCheckInterval', 60,
'alarmsReevaluationInterval', 60
) )
|| ||
jsonb_strip_nulls(profile_data -> 'configuration') jsonb_strip_nulls(profile_data -> 'configuration')
@ -36,7 +39,10 @@ WHERE NOT (
'maxRelationLevelPerCfArgument', 'maxRelationLevelPerCfArgument',
'maxRelatedEntitiesToReturnPerCfArgument', 'maxRelatedEntitiesToReturnPerCfArgument',
'minAllowedDeduplicationIntervalInSecForCF', 'minAllowedDeduplicationIntervalInSecForCF',
'minAllowedAggregationIntervalInSecForCF' 'minAllowedAggregationIntervalInSecForCF',
'intermediateAggregationIntervalInSecForCF',
'cfReevaluationCheckInterval',
'alarmsReevaluationInterval'
] ]
); );

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

@ -666,14 +666,6 @@ public class ActorSystemContext {
@Getter @Getter
private long cfCalculationResultTimeout; private long cfCalculationResultTimeout;
@Value("${actors.calculated_fields.check_interval:60}")
@Getter
private long cfCheckInterval;
@Value("${actors.alarms.reevaluation_interval:60}")
@Getter
private long alarmRulesReevaluationInterval;
@Autowired @Autowired
@Getter @Getter
private MqttClientSettings mqttClientSettings; private MqttClientSettings mqttClientSettings;

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

@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg; import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg;
import org.thingsboard.server.common.msg.cf.CalculatedFieldCacheInitMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldCacheInitMsg;
import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg;
@ -115,6 +116,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
private final TbQueueCalculatedFieldSettings cfSettings; private final TbQueueCalculatedFieldSettings cfSettings;
protected final TenantId tenantId; protected final TenantId tenantId;
private long cfCheckInterval;
protected TbActorCtx ctx; protected TbActorCtx ctx;
CalculatedFieldManagerMessageProcessor(ActorSystemContext systemContext, TenantId tenantId) { CalculatedFieldManagerMessageProcessor(ActorSystemContext systemContext, TenantId tenantId) {
@ -144,10 +147,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
calculatedFields.clear(); calculatedFields.clear();
entityIdCalculatedFields.clear(); entityIdCalculatedFields.clear();
entityIdCalculatedFieldLinks.clear(); entityIdCalculatedFieldLinks.clear();
if (cfsReevaluationTask != null) { cancelReevaluationTask();
cfsReevaluationTask.cancel(true);
cfsReevaluationTask = null;
}
ctx.stop(ctx.getSelf()); ctx.stop(ctx.getSelf());
} }
@ -155,6 +155,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
log.debug("[{}] Processing CF actor init message.", msg.getTenantId().getId()); log.debug("[{}] Processing CF actor init message.", msg.getTenantId().getId());
initEntitiesCache(); initEntitiesCache();
initCalculatedFields(); initCalculatedFields();
cfCheckInterval = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getCfReevaluationCheckInterval);
scheduleCfsReevaluation(); scheduleCfsReevaluation();
msg.getCallback().onSuccess(); msg.getCallback().onSuccess();
} }
@ -190,7 +191,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} catch (Exception e) { } catch (Exception e) {
log.warn("[{}] Failed to trigger CFs reevaluation", tenantId, e); log.warn("[{}] Failed to trigger CFs reevaluation", tenantId, e);
} }
}, systemContext.getCfCheckInterval(), systemContext.getCfCheckInterval(), TimeUnit.SECONDS); }, cfCheckInterval, cfCheckInterval, TimeUnit.SECONDS);
} }
public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException { public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException {
@ -257,10 +258,16 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} }
private void onTenantProfileUpdated(ComponentLifecycleMsg msg, TbCallback callback) { private void onTenantProfileUpdated(ComponentLifecycleMsg msg, TbCallback callback) {
long updatedCfCheckInterval = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getCfReevaluationCheckInterval);
if (cfCheckInterval != updatedCfCheckInterval) {
cfCheckInterval = updatedCfCheckInterval;
cancelReevaluationTask();
scheduleCfsReevaluation();
}
Stream.concat( Stream.concat(
calculatedFields.values().stream(), calculatedFields.values().stream(),
entityIdCalculatedFields.values().stream().flatMap(Collection::stream) entityIdCalculatedFields.values().stream().flatMap(Collection::stream)
).forEach(CalculatedFieldCtx::updateTenantProfileProperties); ).forEach(CalculatedFieldCtx::setTenantProfileProperties);
callback.onSuccess(); callback.onSuccess();
} }
@ -875,4 +882,11 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} }
} }
private void cancelReevaluationTask() {
if (cfsReevaluationTask != null) {
cfsReevaluationTask.cancel(true);
cfsReevaluationTask = null;
}
}
} }

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

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

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

@ -166,9 +166,12 @@ public class TenantProfileController extends BaseController {
" \"maxRelatedEntitiesToReturnPerCfArgument\": 100,\n" + " \"maxRelatedEntitiesToReturnPerCfArgument\": 100,\n" +
" \"maxDataPointsPerRollingArg\": 1000,\n" + " \"maxDataPointsPerRollingArg\": 1000,\n" +
" \"maxStateSizeInKBytes\": 32,\n" + " \"maxStateSizeInKBytes\": 32,\n" +
" \"maxSingleValueArgumentSizeInKBytes\": 2" + " \"maxSingleValueArgumentSizeInKBytes\": 2," +
" \"minAllowedDeduplicationIntervalInSecForCF\": 60" + " \"minAllowedDeduplicationIntervalInSecForCF\": 10," +
" \"minAllowedAggregationIntervalInSecForCF\": 60" + " \"minAllowedAggregationIntervalInSecForCF\": 60," +
" \"intermediateAggregationIntervalInSecForCF\": 300," +
" \"cfReevaluationCheckInterval\": 60," +
" \"alarmsReevaluationInterval\": 60" +
" }\n" + " }\n" +
" },\n" + " },\n" +
" \"default\": false\n" + " \"default\": false\n" +

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

@ -239,7 +239,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
TenantProfile tenantProfile = tenantProfileCache.get(ctx.getTenantId()); TenantProfile tenantProfile = tenantProfileCache.get(ctx.getTenantId());
return tenantProfile != null && tenantProfileId.equals(tenantProfile.getId()); return tenantProfile != null && tenantProfileId.equals(tenantProfile.getId());
}) })
.forEach(CalculatedFieldCtx::updateTenantProfileProperties); .forEach(CalculatedFieldCtx::setTenantProfileProperties);
} }
@Override @Override

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

@ -26,8 +26,6 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; 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.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; 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.geofencing.GeofencingZoneState;
import org.thingsboard.server.utils.CalculatedFieldUtils; import org.thingsboard.server.utils.CalculatedFieldUtils;
import java.io.Closeable; import java.io.Closeable;
@ -41,7 +39,7 @@ import java.util.stream.Collectors;
@Getter @Getter
public abstract class BaseCalculatedFieldState implements CalculatedFieldState, Closeable { public abstract class BaseCalculatedFieldState implements CalculatedFieldState, Closeable {
protected static final long DEFAULT_LAST_UPDATE_TS = -1L; public static final long DEFAULT_LAST_UPDATE_TS = -1L;
protected final EntityId entityId; protected final EntityId entityId;
protected CalculatedFieldCtx ctx; protected CalculatedFieldCtx ctx;
@ -87,16 +85,15 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
if (existingEntry == null || newEntry.isForceResetPrevious()) { if (existingEntry == null || newEntry.isForceResetPrevious()) {
validateNewEntry(key, newEntry); validateNewEntry(key, newEntry);
if (existingEntry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { if (existingEntry instanceof RelatedEntitiesArgumentEntry ||
relatedEntitiesArgumentEntry.updateEntry(newEntry); existingEntry instanceof EntityAggregationArgumentEntry) {
} else if (existingEntry instanceof EntityAggregationArgumentEntry entityAggArgumentEntry) { updateEntry(existingEntry, newEntry);
entityAggArgumentEntry.updateEntry(newEntry);
} else { } else {
arguments.put(key, newEntry); arguments.put(key, newEntry);
} }
entryUpdated = true; entryUpdated = true;
} else { } else {
entryUpdated = existingEntry.updateEntry(newEntry); entryUpdated = updateEntry(existingEntry, newEntry);
} }
if (entryUpdated) { if (entryUpdated) {
@ -104,7 +101,6 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
updatedArguments = new HashMap<>(argumentValues.size()); updatedArguments = new HashMap<>(argumentValues.size());
} }
updatedArguments.put(key, newEntry); updatedArguments.put(key, newEntry);
updateLastUpdateTimestamp(newEntry);
} }
} }
@ -116,6 +112,10 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
return updatedArguments; return updatedArguments;
} }
protected boolean updateEntry(ArgumentEntry existingEntry, ArgumentEntry newEntry) {
return existingEntry.updateEntry(newEntry);
}
@Override @Override
public void reset() { // must reset everything dependent on arguments public void reset() { // must reset everything dependent on arguments
requiredArguments = null; requiredArguments = null;
@ -158,23 +158,29 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
return resultNode; return resultNode;
} }
private void updateLastUpdateTimestamp(ArgumentEntry entry) { public long getLatestTimestamp() {
long newTs = this.latestTimestamp; long latestTs = DEFAULT_LAST_UPDATE_TS;
if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
newTs = singleValueArgumentEntry.getTs(); boolean allDefault = arguments.values().stream().allMatch(entry -> {
} else if (entry instanceof TsRollingArgumentEntry tsRollingArgumentEntry) { if (entry instanceof SingleValueArgumentEntry single) {
Map.Entry<Long, Double> lastEntry = tsRollingArgumentEntry.getTsRecords().lastEntry(); return single.isDefaultValue();
newTs = (lastEntry != null) ? lastEntry.getKey() : DEFAULT_LAST_UPDATE_TS; }
} else if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { return false;
newTs = relatedEntitiesArgumentEntry.getEntityInputs().values().stream() });
.mapToLong(e -> (e instanceof SingleValueArgumentEntry s) ? s.getTs() : DEFAULT_LAST_UPDATE_TS)
.max() for (ArgumentEntry entry : arguments.values()) {
.orElse(DEFAULT_LAST_UPDATE_TS); if (entry instanceof SingleValueArgumentEntry single) {
} else if (entry instanceof GeofencingArgumentEntry geofencingArgumentEntry) { if (allDefault) {
newTs = geofencingArgumentEntry.getZoneStates().values().stream() latestTs = Math.max(latestTs, single.getTs());
.mapToLong(GeofencingZoneState::getTs).max().orElse(DEFAULT_LAST_UPDATE_TS); } else if (!single.isDefaultValue()) {
latestTs = Math.max(latestTs, single.getTs());
}
} else if (entry instanceof HasLatestTs hasLatestTsEntry) {
latestTs = Math.max(latestTs, hasLatestTsEntry.getLatestTs());
}
} }
this.latestTimestamp = Math.max(this.latestTimestamp, newTs);
return latestTs;
} }
protected ReadinessStatus checkReadiness(List<String> requiredArguments, Map<String, ArgumentEntry> currentArguments) { protected ReadinessStatus checkReadiness(List<String> requiredArguments, Map<String, ArgumentEntry> currentArguments) {

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

@ -58,6 +58,7 @@ import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileCon
import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.dao.util.TimeUtils; import org.thingsboard.server.dao.util.TimeUtils;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
@ -119,6 +120,7 @@ public class CalculatedFieldCtx implements Closeable {
private long maxStateSize; private long maxStateSize;
private long maxSingleValueArgumentSize; private long maxSingleValueArgumentSize;
private long intermediateAggregationIntervalMillis;
private boolean relationQueryDynamicArguments; private boolean relationQueryDynamicArguments;
private List<String> mainEntityGeofencingArgumentNames; private List<String> mainEntityGeofencingArgumentNames;
@ -126,6 +128,8 @@ public class CalculatedFieldCtx implements Closeable {
private List<String> relatedEntityArgumentNames; private List<String> relatedEntityArgumentNames;
private long scheduledUpdateIntervalMillis; private long scheduledUpdateIntervalMillis;
private long cfCheckReevaluationInterval;
private long alarmReevaluationInterval;
private Argument propagationArgument; private Argument propagationArgument;
private boolean applyExpressionForResolvedArguments; private boolean applyExpressionForResolvedArguments;
@ -212,8 +216,7 @@ public class CalculatedFieldCtx implements Closeable {
this.alarmService = systemContext.getAlarmService(); this.alarmService = systemContext.getAlarmService();
this.cfProcessingService = systemContext.getCalculatedFieldProcessingService(); this.cfProcessingService = systemContext.getCalculatedFieldProcessingService();
this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; setTenantProfileProperties();
this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
} }
public boolean requiresScheduledReevaluation() { public boolean requiresScheduledReevaluation() {
@ -227,6 +230,12 @@ public class CalculatedFieldCtx implements Closeable {
lastReevaluationTs = now; lastReevaluationTs = now;
return true; return true;
} }
if (entityAggregationConfig.isProduceIntermediateResult()) {
if (now - lastReevaluationTs >= intermediateAggregationIntervalMillis) {
lastReevaluationTs = now;
return true;
}
}
ZonedDateTime lastReevaluationTime = TimeUtils.toZonedDateTime(lastReevaluationTs, entityAggregationConfig.getInterval().getZoneId()); ZonedDateTime lastReevaluationTime = TimeUtils.toZonedDateTime(lastReevaluationTs, entityAggregationConfig.getInterval().getZoneId());
long previousIntervalEndTs = entityAggregationConfig.getInterval().getDateTimeIntervalEndTs(lastReevaluationTime); long previousIntervalEndTs = entityAggregationConfig.getInterval().getDateTimeIntervalEndTs(lastReevaluationTime);
if (now >= previousIntervalEndTs) { if (now >= previousIntervalEndTs) {
@ -237,7 +246,7 @@ public class CalculatedFieldCtx implements Closeable {
boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation(); boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation();
if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) { if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) {
if (requiresScheduledReevaluation) { if (requiresScheduledReevaluation) {
long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval()); long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(alarmReevaluationInterval);
if (now - lastReevaluationTs >= reevaluationIntervalMillis) { if (now - lastReevaluationTs >= reevaluationIntervalMillis) {
lastReevaluationTs = now; lastReevaluationTs = now;
return true; return true;
@ -292,9 +301,13 @@ public class CalculatedFieldCtx implements Closeable {
} }
} }
public void updateTenantProfileProperties() { public void setTenantProfileProperties() {
this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; ApiLimitService apiLimitService = systemContext.getApiLimitService();
this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
this.maxSingleValueArgumentSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
this.intermediateAggregationIntervalMillis = TimeUnit.SECONDS.toMillis(apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getIntermediateAggregationIntervalInSecForCF));
this.cfCheckReevaluationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getCfReevaluationCheckInterval);
this.alarmReevaluationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getAlarmsReevaluationInterval);
} }
public double evaluateSimpleExpression(Expression expression, CalculatedFieldState state) { public double evaluateSimpleExpression(Expression expression, CalculatedFieldState state) {
@ -664,7 +677,8 @@ public class CalculatedFieldCtx implements Closeable {
&& other.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) { && other.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) {
boolean metricsChanged = !Objects.equals(thisConfig.getMetrics(), otherConfig.getMetrics()); boolean metricsChanged = !Objects.equals(thisConfig.getMetrics(), otherConfig.getMetrics());
boolean watermarkChanged = !Objects.equals(thisConfig.getWatermark(), otherConfig.getWatermark()); boolean watermarkChanged = !Objects.equals(thisConfig.getWatermark(), otherConfig.getWatermark());
return metricsChanged || watermarkChanged; boolean produceIntermediateResultChanged = thisConfig.isProduceIntermediateResult() != otherConfig.isProduceIntermediateResult();
return metricsChanged || watermarkChanged || produceIntermediateResultChanged;
} }
return false; return false;
} }

22
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/HasLatestTs.java

@ -0,0 +1,22 @@
/**
* 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;
public interface HasLatestTs {
long getLatestTs();
}

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

@ -162,9 +162,7 @@ public class SingleValueArgumentEntry implements ArgumentEntry {
public boolean updateEntry(ArgumentEntry entry) { public boolean updateEntry(ArgumentEntry entry) {
if (entry instanceof SingleValueArgumentEntry singleValueEntry) { if (entry instanceof SingleValueArgumentEntry singleValueEntry) {
if (singleValueEntry.getTs() < this.ts) { if (singleValueEntry.getTs() < this.ts) {
if (!isDefaultValue()) { return false;
return false;
}
} }
Long newVersion = singleValueEntry.getVersion(); Long newVersion = singleValueEntry.getVersion();

9
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java

@ -31,11 +31,13 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState.DEFAULT_LAST_UPDATE_TS;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Slf4j @Slf4j
public class TsRollingArgumentEntry implements ArgumentEntry { public class TsRollingArgumentEntry implements ArgumentEntry, HasLatestTs {
private Integer limit; private Integer limit;
private Long timeWindow; private Long timeWindow;
@ -83,6 +85,11 @@ public class TsRollingArgumentEntry implements ArgumentEntry {
return tsRecords; return tsRecords;
} }
public long getLatestTs() {
var lastEntry = tsRecords.lastEntry();
return (lastEntry != null) ? lastEntry.getKey() : DEFAULT_LAST_UPDATE_TS;
}
@Override @Override
public TbelCfArg toTbelCfArg() { public TbelCfArg toTbelCfArg() {
List<TbelCfTsDoubleVal> values = new ArrayList<>(tsRecords.size()); List<TbelCfTsDoubleVal> values = new ArrayList<>(tsRecords.size());

18
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java

@ -23,14 +23,17 @@ import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; 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.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.HasLatestTs;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState.DEFAULT_LAST_UPDATE_TS;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
public class RelatedEntitiesArgumentEntry implements ArgumentEntry { public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs {
private final Map<EntityId, ArgumentEntry> entityInputs; private final Map<EntityId, ArgumentEntry> entityInputs;
@ -46,6 +49,19 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry {
return entityInputs; return entityInputs;
} }
@Override
public long getLatestTs() {
long latestTs = DEFAULT_LAST_UPDATE_TS;
for (ArgumentEntry entry : entityInputs.values()) {
if (entry instanceof SingleValueArgumentEntry single) {
if (!single.isDefaultValue()) {
latestTs = Math.max(latestTs, single.getTs());
}
}
}
return latestTs;
}
@Override @Override
public boolean updateEntry(ArgumentEntry entry) { public boolean updateEntry(ArgumentEntry entry) {
if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {

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

@ -60,9 +60,10 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
private AggInterval interval; private AggInterval interval;
private long watermarkDuration; private long watermarkDuration;
private long checkInterval;
private Map<String, AggMetric> metrics; private Map<String, AggMetric> metrics;
private boolean produceIntermediateResult;
private EntityAggregationDebugArgumentsTracker debugTracker; private EntityAggregationDebugArgumentsTracker debugTracker;
private CalculatedFieldProcessingService cfProcessingService; private CalculatedFieldProcessingService cfProcessingService;
@ -78,9 +79,9 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
var configuration = (EntityAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); var configuration = (EntityAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
Watermark watermark = configuration.getWatermark(); Watermark watermark = configuration.getWatermark();
watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration()); watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration());
checkInterval = TimeUnit.SECONDS.toMillis(ctx.getSystemContext().getCfCheckInterval());
interval = configuration.getInterval(); interval = configuration.getInterval();
metrics = configuration.getMetrics(); metrics = configuration.getMetrics();
produceIntermediateResult = configuration.isProduceIntermediateResult();
} }
@Override @Override
@ -204,10 +205,12 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
handleExpiredInterval(intervalEntry, args, results); handleExpiredInterval(intervalEntry, args, results);
expiredIntervals.add(intervalEntry); expiredIntervals.add(intervalEntry);
} else if (now - startTs >= intervalEntry.getIntervalDuration()) { } else if (now - startTs >= intervalEntry.getIntervalDuration()) {
handleActiveInterval(intervalEntry, args, results); handleActiveInterval(ctx.getCfCheckReevaluationInterval(), intervalEntry, args, results);
if (watermarkDuration == 0) { if (watermarkDuration == 0) {
expiredIntervals.add(intervalEntry); expiredIntervals.add(intervalEntry);
} }
} else if (produceIntermediateResult) {
handleActiveInterval(ctx.getIntermediateAggregationIntervalMillis(), intervalEntry, args, results);
} }
} }
@ -225,11 +228,12 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
}); });
} }
private void handleActiveInterval(AggIntervalEntry intervalEntry, private void handleActiveInterval(long cfCheckInterval,
AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args, Map<String, AggIntervalEntryStatus> args,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) { Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
args.forEach((argName, argEntryIntervalStatus) -> { args.forEach((argName, argEntryIntervalStatus) -> {
if (argEntryIntervalStatus.intervalPassed(checkInterval)) { if (argEntryIntervalStatus.intervalPassed(cfCheckInterval)) {
if (argEntryIntervalStatus.argsUpdated()) { if (argEntryIntervalStatus.argsUpdated()) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis()); argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
argEntryIntervalStatus.setLastArgsRefreshTs(DEFAULT_LAST_UPDATE_TS); argEntryIntervalStatus.setLastArgsRefreshTs(DEFAULT_LAST_UPDATE_TS);

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

@ -225,6 +225,20 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
.build()); .build());
} }
@Override
protected boolean updateEntry(ArgumentEntry existingArgumentEntry, ArgumentEntry newArgumentEntry) {
if (!(existingArgumentEntry instanceof SingleValueArgumentEntry existingEntry) ||
!(newArgumentEntry instanceof SingleValueArgumentEntry newEntry)) {
return super.updateEntry(existingArgumentEntry, newArgumentEntry);
}
if (newEntry.getTs() < existingEntry.getTs()) {
if (existingEntry.isDefaultValue()) {
existingEntry.setTs(newEntry.getTs());
}
}
return super.updateEntry(existingEntry, newEntry);
}
public void processAlarmAction(Alarm alarm, ActionType action) { public void processAlarmAction(Alarm alarm, ActionType action) {
switch (action) { switch (action) {
case ALARM_ACK -> processAlarmAck(alarm); case ALARM_ACK -> processAlarmAck(alarm);

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

@ -157,7 +157,7 @@ public class AlarmRuleState {
private AlarmEvalResult evalDuration(CalculatedFieldCtx ctx) { private AlarmEvalResult evalDuration(CalculatedFieldCtx ctx) {
if (eval(condition.getExpression(), ctx)) { if (eval(condition.getExpression(), ctx)) {
long ts = System.currentTimeMillis(); long ts = System.currentTimeMillis();
if (firstEventTs == 0) { if (firstEventTs <= 0) {
firstEventTs = state.getLatestTimestamp(); firstEventTs = state.getLatestTimestamp();
} }
lastCheckTs = ts; lastCheckTs = ts;

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

@ -25,13 +25,16 @@ import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; 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.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.HasLatestTs;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState.DEFAULT_LAST_UPDATE_TS;
@Data @Data
@Slf4j @Slf4j
public class GeofencingArgumentEntry implements ArgumentEntry { public class GeofencingArgumentEntry implements ArgumentEntry, HasLatestTs {
private Map<EntityId, GeofencingZoneState> zoneStates; private Map<EntityId, GeofencingZoneState> zoneStates;
@ -58,6 +61,12 @@ public class GeofencingArgumentEntry implements ArgumentEntry {
return zoneStates; return zoneStates;
} }
@Override
public long getLatestTs() {
return zoneStates.values().stream()
.mapToLong(GeofencingZoneState::getTs).max().orElse(DEFAULT_LAST_UPDATE_TS);
}
@Override @Override
public boolean updateEntry(ArgumentEntry entry) { public boolean updateEntry(ArgumentEntry entry) {
if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {

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

@ -104,7 +104,6 @@ public class CalculatedFieldArgumentUtils {
public static TsKvEntry createDefaultTsKvEntry(Argument argument, long ts) { public static TsKvEntry createDefaultTsKvEntry(Argument argument, long ts) {
return new BasicTsKvEntry(ts, createDefaultKvEntry(argument), DEFAULT_VERSION); return new BasicTsKvEntry(ts, createDefaultKvEntry(argument), DEFAULT_VERSION);
} }
public static AttributeKvEntry createDefaultAttributeEntry(Argument argument, long ts) { public static AttributeKvEntry createDefaultAttributeEntry(Argument argument, long ts) {
return new BaseAttributeKvEntry(createDefaultKvEntry(argument), ts, DEFAULT_VERSION); return new BaseAttributeKvEntry(createDefaultKvEntry(argument), ts, DEFAULT_VERSION);
} }

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

@ -538,11 +538,6 @@ actors:
configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}" configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}"
# Time in seconds to receive calculation result. # Time in seconds to receive calculation result.
calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" 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:60}"
debug: debug:
settings: settings:

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

@ -21,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.action.TbAlarmResult; import org.thingsboard.rule.engine.action.TbAlarmResult;
@ -86,10 +85,6 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await;
@Slf4j @Slf4j
@DaoSqlTest @DaoSqlTest
@TestPropertySource(properties = {
"actors.calculated_fields.check_interval=1",
"actors.alarms.reevaluation_interval=1"
})
public class AlarmRulesTest extends AbstractControllerTest { public class AlarmRulesTest extends AbstractControllerTest {
@MockitoSpyBean @MockitoSpyBean
@ -105,6 +100,13 @@ public class AlarmRulesTest extends AbstractControllerTest {
@Before @Before
public void beforeEach() throws Exception { public void beforeEach() throws Exception {
loginSysAdmin();
updateDefaultTenantProfileConfig(tenantProfileConfig -> {
tenantProfileConfig.setCfReevaluationCheckInterval(1);
tenantProfileConfig.setAlarmsReevaluationInterval(1);
});
loginTenantAdmin(); loginTenantAdmin();
device = createDevice("Device A", "aaa"); device = createDevice("Device A", "aaa");
deviceId = device.getId(); deviceId = device.getId();
@ -194,6 +196,30 @@ public class AlarmRulesTest extends AbstractControllerTest {
}); });
} }
@Test
public void testCreateAlarm_eventBeforeDefaultTs() throws Exception {
Argument temperatureArgument = new Argument();
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
temperatureArgument.setDefaultValue("0");
Map<String, Argument> arguments = Map.of(
"temperature", temperatureArgument
);
Map<AlarmSeverity, Condition> createRules = Map.of(
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
);
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"values\": {\"temperature\": 50}, \"ts\": " + (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + "}"));
checkAlarmResult(calculatedField, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
}
@Test @Test
public void testCreateAlarm_repeatingCondition() throws Exception { public void testCreateAlarm_repeatingCondition() throws Exception {
Argument temperatureArgument = new Argument(); Argument temperatureArgument = new Argument();
@ -350,6 +376,32 @@ public class AlarmRulesTest extends AbstractControllerTest {
}); });
} }
@Test
public void testCreateAlarm_durationCondition_defaultValue() {
Argument powerConsumptionArgument = new Argument();
powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null));
powerConsumptionArgument.setDefaultValue("3500");
Map<String, Argument> arguments = Map.of(
"powerConsumption", powerConsumptionArgument
);
long createDurationMs = 2000L;
Map<AlarmSeverity, Condition> createRules = Map.of(
AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null,
new AlarmConditionValue<Long>(2000L, null), null)
);
CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds",
arguments, createRules, null);
checkAlarmResult(calculatedField, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000);
});
}
@Test @Test
public void testCreateAlarm_currentOwnerArgument() throws Exception { public void testCreateAlarm_currentOwnerArgument() throws Exception {
Argument temperatureArgument = new Argument(); Argument temperatureArgument = new Argument();

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

@ -580,6 +580,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
@Test @Test
public void testScriptCalculatedFieldWhenUsedLatestTsInScript() throws Exception { public void testScriptCalculatedFieldWhenUsedLatestTsInScript() throws Exception {
Device testDevice = createDevice("Test device", "1234567890"); Device testDevice = createDevice("Test device", "1234567890");
long ts = System.currentTimeMillis() - 300000L; long ts = System.currentTimeMillis() - 300000L;
postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)); postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts));
@ -614,6 +615,90 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
}); });
} }
@Test
public void testSimpleCalculatedFieldWhenUseLatestTsIsTrueAndDefaultArguments() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setName("a + b + c");
calculatedField.setDebugSettings(DebugSettings.all());
calculatedField.setConfigurationVersion(1);
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument argument1 = new Argument();
ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null);
argument1.setRefEntityKey(refEntityKey1);
argument1.setDefaultValue("100");
Argument argument2 = new Argument();
ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("b", ArgumentType.TS_LATEST, null);
argument2.setRefEntityKey(refEntityKey2);
argument2.setDefaultValue("200");
Argument argument3 = new Argument();
ReferencedEntityKey refEntityKey3 = new ReferencedEntityKey("c", ArgumentType.TS_LATEST, null);
argument3.setRefEntityKey(refEntityKey3);
argument3.setDefaultValue("300");
config.setArguments(Map.of("a", argument1, "b", argument2, "c", argument3));
config.setExpression("a + b + c");
TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("d");
output.setDecimalsByDefault(0);
config.setOutput(output);
config.setUseLatestTs(true);
calculatedField.setConfiguration(config);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
await().alias("create CF -> perform initial calculation with default arguments").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode d = getLatestTelemetry(testDevice.getId(), "d");
assertThat(d).isNotNull();
assertThat(d.get("d").get(0).get("value").asText()).isEqualTo("600");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}"));
await().alias("update telemetry -> save result with ts of 'a' argument").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode keys = getLatestTelemetry(testDevice.getId(), "d", "a");
assertThat(keys).isNotNull();
String aTs = keys.get("a").get(0).get("ts").asText();
assertThat(keys.get("d").get(0).get("ts").asText()).isEqualTo(aTs);
assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("510");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"b\":20}"));
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"c\":30}"));
await().alias("update telemetry -> save result with latest ts of updated arguments").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode keys = getLatestTelemetry(testDevice.getId(), "d");
assertThat(keys).isNotNull();
assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("60");
});
String latestTs = getLatestTelemetry(testDevice.getId(), "d").get("d").get(0).get("ts").asText();
doDelete("/api/plugins/telemetry/DEVICE/" + testDevice.getId() + "/timeseries/delete?keys=b&deleteAllDataForKeys=true").andExpect(status().isOk());
await().alias("delete telemetry -> save result with previous latest ts and default argument").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode keys = getLatestTelemetry(testDevice.getId(), "d");
assertThat(keys).isNotNull();
assertThat(keys.get("d").get(0).get("ts").asText()).isEqualTo(latestTs);
assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("240");
});
}
@Test @Test
public void testSimpleCalculatedFieldWhenCtxBecameUninitialized() throws Exception { public void testSimpleCalculatedFieldWhenCtxBecameUninitialized() throws Exception {
Device testDevice = createDevice("Test device", "1234567890"); Device testDevice = createDevice("Test device", "1234567890");

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

@ -20,7 +20,6 @@ import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.test.annotation.DirtiesContext; 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.Device;
import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.User;
@ -54,9 +53,6 @@ import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTE
@DaoSqlTest @DaoSqlTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@TestPropertySource(properties = {
"actors.calculated_fields.check_interval=1"
})
public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest { public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest {
private Tenant savedTenant; private Tenant savedTenant;
@ -68,6 +64,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
updateDefaultTenantProfileConfig(tenantProfileConfig -> { updateDefaultTenantProfileConfig(tenantProfileConfig -> {
tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1);
tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1); tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1);
tenantProfileConfig.setCfReevaluationCheckInterval(1);
}); });
Tenant tenant = new Tenant(); Tenant tenant = new Tenant();
@ -97,9 +94,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
Device device = createDevice("Device", "1234567890111"); Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long intervalEndTs = customInterval.getCurrentIntervalEndTs(); createConsumptionCF(device.getId(), customInterval, null);
CalculatedField consumptionCF = createConsumptionCF(device.getId(), customInterval, null);
long interval = customInterval.getCurrentIntervalDurationMillis(); long interval = customInterval.getCurrentIntervalDurationMillis();
await().alias("create CF and no telemetry during interval -> save metric with default value") await().alias("create CF and no telemetry during interval -> save metric with default value")
@ -118,8 +114,9 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
Device device = createDevice("Device", "1234567890111"); Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
createConsumptionCF(device.getId(), customInterval, null);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000; long tsBeforeInterval = currentIntervalStartTs - 1000;
long tsInInterval_1 = currentIntervalStartTs + 1000; long tsInInterval_1 = currentIntervalStartTs + 1000;
@ -131,7 +128,6 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis(); long interval = customInterval.getCurrentIntervalDurationMillis();
CalculatedField consumptionCF = createConsumptionCF(device.getId(), customInterval, null);
await().alias("create CF -> perform aggregation after interval end") await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS) .atMost(2 * interval, TimeUnit.MILLISECONDS)
@ -161,8 +157,10 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
Device device = createDevice("Device", "1234567890111"); Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
Watermark watermark = new Watermark(10);
createConsumptionCF(device.getId(), customInterval, watermark);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000L; long tsBeforeInterval = currentIntervalStartTs - 1000L;
long tsInInterval_1 = currentIntervalStartTs + 1000L; long tsInInterval_1 = currentIntervalStartTs + 1000L;
@ -174,8 +172,6 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis(); long interval = customInterval.getCurrentIntervalDurationMillis();
Watermark watermark = new Watermark(10);
CalculatedField consumptionCF = createConsumptionCF(device.getId(), customInterval, watermark);
await().alias("create CF -> perform aggregation after interval end") await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS) .atMost(2 * interval, TimeUnit.MILLISECONDS)
@ -235,27 +231,21 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
Device device = createDevice("Device", "1234567890111"); Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
createCFWith2Args(device.getId(), customInterval, null);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000; long tsBeforeInterval = currentIntervalStartTs - 1000;
long tsInInterval_1 = currentIntervalStartTs + 1000; long tsInInterval_1 = currentIntervalStartTs + 1000;
long tsInInterval_2 = currentIntervalStartTs + 500; long tsInInterval_2 = currentIntervalStartTs + 500;
long tsInInterval_3 = currentIntervalStartTs + 200; 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\":120, \"temperature\":43}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100, \"temperature\":39}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180, \"temperature\":27}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120, \"temperature\":50}}", tsInInterval_3));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"temperature\":43}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"temperature\":39}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"temperature\":27}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"temperature\":50}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis(); long interval = customInterval.getCurrentIntervalDurationMillis();
CalculatedField consumptionCF = createCFWith2Args(device.getId(), customInterval, null);
await().alias("create CF -> perform aggregation after interval end") await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS) .atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)

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

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

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

@ -43,6 +43,7 @@ public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsB
private AggInterval interval; private AggInterval interval;
@Valid @Valid
private Watermark watermark; private Watermark watermark;
private boolean produceIntermediateResult;
@Valid @Valid
@NotNull @NotNull
private Output output; private Output output;

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

@ -190,10 +190,19 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxStateSizeInKBytes = 32; private long maxStateSizeInKBytes = 32;
@Schema(example = "2") @Schema(example = "2")
private long maxSingleValueArgumentSizeInKBytes = 2; private long maxSingleValueArgumentSizeInKBytes = 2;
@Schema(example = "60") @Schema(example = "10")
private long minAllowedDeduplicationIntervalInSecForCF = 60; private long minAllowedDeduplicationIntervalInSecForCF = 10;
@Schema(example = "60") @Schema(example = "60")
private long minAllowedAggregationIntervalInSecForCF = 60; private long minAllowedAggregationIntervalInSecForCF = 60;
@Builder.Default
@Schema(example = "300")
private long intermediateAggregationIntervalInSecForCF = 300;
@Builder.Default
@Schema(example = "60")
private long cfReevaluationCheckInterval = 60;
@Builder.Default
@Schema(example = "60")
private long alarmsReevaluationInterval = 60;
@Override @Override
public long getProfileThreshold(ApiUsageRecordKey key) { public long getProfileThreshold(ApiUsageRecordKey key) {
@ -249,4 +258,16 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
return maxRuleNodeExecutionsPerMessage; return maxRuleNodeExecutionsPerMessage;
} }
public long getCfReevaluationCheckInterval() {
return cfReevaluationCheckInterval <= 0 ? 60 : cfReevaluationCheckInterval;
}
public long getAlarmsReevaluationInterval() {
return alarmsReevaluationInterval <= 0 ? 60 : alarmsReevaluationInterval;
}
public long getIntermediateAggregationIntervalInSecForCF() {
return intermediateAggregationIntervalInSecForCF <= 0 ? 300 : intermediateAggregationIntervalInSecForCF;
}
} }

2
common/queue/pom.xml

@ -69,7 +69,7 @@
<artifactId>kafka-clients</artifactId> <artifactId>kafka-clients</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.lz4</groupId> <groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId> <artifactId>lz4-java</artifactId>
</dependency> </dependency>
<dependency> <dependency>

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

@ -50,7 +50,7 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
validateCalculatedFieldConfiguration(calculatedField); validateCalculatedFieldConfiguration(calculatedField);
validateSchedulingConfiguration(tenantId, calculatedField); validateSchedulingConfiguration(tenantId, calculatedField);
validateRelationQuerySourceArguments(tenantId, calculatedField); validateRelationQuerySourceArguments(tenantId, calculatedField);
validateAggregationConfiguration(tenantId, calculatedField); validateRelatedAggregationConfiguration(tenantId, calculatedField);
validateEntityAggregationConfiguration(tenantId, calculatedField); validateEntityAggregationConfiguration(tenantId, calculatedField);
} }
@ -119,7 +119,7 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
wrapAsDataValidation(() -> relationQueryDynamicSourceConfiguration.validateMaxRelationLevel(argumentName, maxRelationLevel))); wrapAsDataValidation(() -> relationQueryDynamicSourceConfiguration.validateMaxRelationLevel(argumentName, maxRelationLevel)));
} }
private void validateAggregationConfiguration(TenantId tenantId, CalculatedField calculatedField) { private void validateRelatedAggregationConfiguration(TenantId tenantId, CalculatedField calculatedField) {
if (!(calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfiguration)) { if (!(calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfiguration)) {
return; return;
} }

289
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java

@ -15,10 +15,11 @@
*/ */
package org.thingsboard.server.msa.connectivity; package org.thingsboard.server.msa.connectivity;
import com.google.gson.JsonObject;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy;
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
@ -30,26 +31,83 @@ import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rest.client.RestClient; import org.thingsboard.rest.client.RestClient;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmInfo;
import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus;
import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmSeverity;
import org.thingsboard.server.common.data.domain.Domain;
import org.thingsboard.server.common.data.domain.DomainInfo;
import org.thingsboard.server.common.data.id.NotificationTargetId;
import org.thingsboard.server.common.data.id.NotificationTemplateId;
import org.thingsboard.server.common.data.id.UUIDBased;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.mobile.app.MobileApp;
import org.thingsboard.server.common.data.mobile.app.MobileAppStatus;
import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundle;
import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundleInfo;
import org.thingsboard.server.common.data.notification.Notification;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationRequest;
import org.thingsboard.server.common.data.notification.NotificationRequestConfig;
import org.thingsboard.server.common.data.notification.NotificationRequestInfo;
import org.thingsboard.server.common.data.notification.NotificationRequestPreview;
import org.thingsboard.server.common.data.notification.NotificationType;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig;
import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings;
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig;
import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter;
import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.HasSubject;
import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig;
import org.thingsboard.server.common.data.notification.template.SmsDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.oauth2.PlatformType;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.common.data.query.AvailableEntityKeys;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.EntityTypeFilter;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.AbstractContainerTest;
import org.thingsboard.server.msa.TestProperties; import org.thingsboard.server.msa.TestProperties;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.EMAIL;
import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.MICROSOFT_TEAMS;
import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.WEB;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype;
import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultTenantAdmin;
public class JavaRestClientTest extends AbstractContainerTest { public class JavaRestClientTest extends AbstractContainerTest {
public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test";
public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL;
private RestClient restClient; private RestClient restClient;
private Tenant tenant;
private User user;
@BeforeClass @BeforeClass
public void beforeClass() throws Exception { public void beforeClass() throws Exception {
@ -77,11 +135,25 @@ public class JavaRestClientTest extends AbstractContainerTest {
@BeforeMethod @BeforeMethod
public void setUp() throws Exception { public void setUp() throws Exception {
restClient.login("tenant@thingsboard.org", "tenant"); restClient.login("sysadmin@thingsboard.org", "sysadmin");
// create tenant and tenant admin
tenant = new Tenant();
tenant.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.randomAlphabetic(5));
tenant = restClient.saveTenant(tenant);
String email = RandomStringUtils.randomAlphabetic(5) + "@gmail.com";
user = restClient.saveUser(defaultTenantAdmin(tenant.getId(), email), false);
restClient.activateUser(user.getId(), "password123", false);
restClient.login(email, "password123");
} }
@AfterMethod @AfterMethod
public void tearDown() { public void tearDown() {
restClient.login("sysadmin@thingsboard.org", "sysadmin");
if (tenant != null) {
restClient.deleteTenant(tenant.getId());
}
} }
@Test @Test
@ -123,6 +195,219 @@ public class JavaRestClientTest extends AbstractContainerTest {
PageData<AlarmInfo> allClearedAlarms = restClient.getAllAlarms(AlarmSearchStatus.CLEARED, null, new TimePageLink(10, 0), null); PageData<AlarmInfo> allClearedAlarms = restClient.getAllAlarms(AlarmSearchStatus.CLEARED, null, new TimePageLink(10, 0), null);
assertThat(allClearedAlarms.getData()).hasSize(0); assertThat(allClearedAlarms.getData()).hasSize(0);
}
@Test
public void testTimeSeriesByReadTsKvQueries() {
Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5)));
assertThat(device).isNotNull();
DeviceCredentials deviceCredentials = restClient.getDeviceCredentialsByDeviceId(device.getId()).get();
for (int i = 0; i < 3; i++) {
JsonObject values = new JsonObject();
values.addProperty("temperature", i + 25);
testRestClient.postTelemetry(deviceCredentials.getCredentialsId(), JacksonUtil.toJsonNode(createPayload().toString()));
}
restClient.saveEntityTelemetry(device.getId(), "ts", JacksonUtil.toJsonNode("{\"temperature\": 25, \"humidity\": 60}"));
restClient.saveEntityTelemetry(device.getId(), "ts", JacksonUtil.toJsonNode("{\"temperature\": 27, \"humidity\": 59}"));
restClient.saveEntityTelemetry(device.getId(), "ts", JacksonUtil.toJsonNode("{\"temperature\": 33, \"humidity\": 62}"));
EntityTypeFilter filter = new EntityTypeFilter();
filter.setEntityType(EntityType.DEVICE);
var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.DESC), false);
var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"));
EntityDataQuery entityDataQuery = new EntityDataQuery(filter, pageLink, entityFields, null, null);
AvailableEntityKeys availableEntityKeys = restClient.findAvailableEntityKeysByQuery(entityDataQuery, true, true, null);
assertThat(availableEntityKeys).isNotNull();
assertThat(availableEntityKeys.timeseries()).contains("temperature", "humidity");
}
@Test
public void testFindNotifications() {
NotificationTarget notificationTarget = createNotificationTarget(user.getId());
String notificationText1 = "Notification 1";
NotificationTemplate notificationTemplate = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, notificationText1, new NotificationDeliveryMethod[]{WEB});
NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationTemplate.getId());
String notificationText2 = "Notification 2";
NotificationTemplate notificationTemplate2 = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, notificationText2, new NotificationDeliveryMethod[]{WEB});
NotificationRequest notificationRequest2 = submitNotificationRequest(notificationTarget.getId(), notificationTemplate2.getId());
PageData<NotificationRequestInfo> initialRequests = restClient.getNotificationRequests(new PageLink(30));
assertThat(initialRequests.getTotalElements()).isGreaterThanOrEqualTo(2);
NotificationRequestInfo notificationRequestInfo = restClient.getNotificationRequestById(notificationRequest.getId()).get();
assertThat(notificationRequestInfo.getName()).isEqualTo(notificationRequest.getName());
assertThat(notificationRequestInfo.getTemplateName()).isEqualTo(notificationTemplate.getName());
NotificationRequestPreview requestPreview = restClient.getNotificationRequestPreview(notificationRequest, 10);
assertThat(requestPreview.getTotalRecipientsCount()).isEqualTo(1);
assertThat(requestPreview.getRecipientsPreview()).isEqualTo(List.of(user.getEmail()));
PageData<Notification> notifications = restClient.getNotifications(false, WEB, new PageLink(30));
assertThat(notifications.getTotalElements()).isEqualTo(2);
Integer unreadCount = restClient.getUnreadNotificationsCount(WEB);
assertThat(unreadCount).isEqualTo(2);
restClient.markNotificationAsRead(notifications.getData().get(0).getId());
Integer unreadCountAfterRead = restClient.getUnreadNotificationsCount(WEB);
assertThat(unreadCountAfterRead).isEqualTo(1);
restClient.markAllNotificationsAsRead(WEB);
Integer unreadCountAfterAllRead = restClient.getUnreadNotificationsCount(WEB);
assertThat(unreadCountAfterAllRead).isEqualTo(0);
restClient.deleteNotification(notifications.getData().get(0).getId());
notifications = restClient.getNotifications(false, WEB, new PageLink(30));
assertThat(notifications.getTotalElements()).isEqualTo(1);
restClient.deleteNotificationRequest(notificationRequest.getId());
PageData<NotificationRequestInfo> requestsAfterUpdate = restClient.getNotificationRequests(new PageLink(30));
assertThat(requestsAfterUpdate.getTotalElements()).isEqualTo(initialRequests.getTotalElements() - 1);
List<NotificationDeliveryMethod> availableDeliveryMethods = restClient.getAvailableDeliveryMethods();
assertThat(availableDeliveryMethods).contains(WEB, EMAIL, MICROSOFT_TEAMS);
}
@Test
public void testSaveNotificationSettings() {
NotificationSettings settings = new NotificationSettings();
SlackNotificationDeliveryMethodConfig slackConfig = new SlackNotificationDeliveryMethodConfig();
String slackToken = "xoxb-123123123";
slackConfig.setBotToken(slackToken);
settings.setDeliveryMethodsConfigs(Map.of(
NotificationDeliveryMethod.SLACK, slackConfig
));
restClient.saveNotificationSettings(settings);
NotificationSettings savedSettings = restClient.getNotificationSettings().get();
assertThat(savedSettings.getDeliveryMethodsConfigs()).hasSize(1);
assertThat(savedSettings.getDeliveryMethodsConfigs().get(slackConfig.getMethod())).isEqualTo(slackConfig);
// save user notification settings
var entityActionNotificationPref = new UserNotificationSettings.NotificationPref();
entityActionNotificationPref.setEnabled(true);
entityActionNotificationPref.setEnabledDeliveryMethods(Map.of(
NotificationDeliveryMethod.WEB, true,
NotificationDeliveryMethod.SMS, false,
NotificationDeliveryMethod.EMAIL, false
));
UserNotificationSettings userNotificationSettings = new UserNotificationSettings(Map.of(
NotificationType.ENTITY_ACTION, entityActionNotificationPref
));
UserNotificationSettings saved = restClient.saveUserNotificationSettings(userNotificationSettings);
UserNotificationSettings retrieved = restClient.getUserNotificationSettings().get();
assertThat(retrieved).isEqualTo(saved);
}
@Test
public void testSaveDomain() {
restClient.login("sysadmin@thingsboard.org", "sysadmin");
Domain domain = new Domain();
String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase();
domain.setName(prefix + ".test.com");
Domain savedDomain = restClient.saveDomain(domain);
assertThat(savedDomain.getName()).isEqualTo(domain.getName());
PageData<DomainInfo> domainInfos = restClient.getTenantDomainInfos(new PageLink(10, 0 , prefix));
assertThat(domainInfos.getData()).hasSize(1);
}
@Test
public void testSaveMobileApp() {
restClient.login("sysadmin@thingsboard.org", "sysadmin");
MobileApp mobileApp = new MobileApp();
String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase();
mobileApp.setPkgName(prefix + "test.app.apple");
mobileApp.setPlatformType(PlatformType.ANDROID);
mobileApp.setAppSecret(RandomStringUtils.randomAlphabetic(20));
mobileApp.setStatus(MobileAppStatus.DRAFT);
MobileApp savedMobileApp = restClient.saveMobileApp(mobileApp);
assertThat(savedMobileApp.getName()).isEqualTo(mobileApp.getName());
PageData<MobileApp> retrieved = restClient.getTenantMobileApps(new PageLink(10, 0, prefix));
assertThat(retrieved.getData()).hasSize(1);
MobileAppBundle mobileAppBundle = new MobileAppBundle();
String bundlePrefix = RandomStringUtils.randomAlphabetic(5).toLowerCase();
mobileAppBundle.setTitle(bundlePrefix + "Test Bundle");
mobileAppBundle.setAndroidAppId(savedMobileApp.getId());
MobileAppBundle savedMobileAppBundle = restClient.saveMobileBundle(mobileAppBundle);
PageData<MobileAppBundleInfo> bundleInfos = restClient.getTenantMobileBundleInfos(new PageLink(10, 0, bundlePrefix));
assertThat(bundleInfos.getData()).hasSize(1);
}
private NotificationTarget createNotificationTarget(UserId... usersIds) {
UserListFilter filter = new UserListFilter();
filter.setUsersIds(Arrays.stream(usersIds).map(UUIDBased::getId).toList());
NotificationTarget notificationTarget = new NotificationTarget();
notificationTarget.setName(filter.toString() + org.apache.commons.lang3.RandomStringUtils.randomNumeric(5));
PlatformUsersNotificationTargetConfig targetConfig = new PlatformUsersNotificationTargetConfig();
targetConfig.setUsersFilter(filter);
notificationTarget.setConfiguration(targetConfig);
return restClient.saveNotificationTarget(notificationTarget);
}
private NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject,
String text, NotificationDeliveryMethod... deliveryMethods) {
NotificationTemplate notificationTemplate = new NotificationTemplate();
notificationTemplate.setName("Notification template: " + RandomStringUtils.randomAlphabetic(5));
notificationTemplate.setNotificationType(notificationType);
NotificationTemplateConfig config = new NotificationTemplateConfig();
config.setDeliveryMethodsTemplates(new HashMap<>());
for (NotificationDeliveryMethod deliveryMethod : deliveryMethods) {
DeliveryMethodNotificationTemplate deliveryMethodNotificationTemplate;
switch (deliveryMethod) {
case WEB: {
deliveryMethodNotificationTemplate = new WebDeliveryMethodNotificationTemplate();
break;
}
case EMAIL: {
deliveryMethodNotificationTemplate = new EmailDeliveryMethodNotificationTemplate();
break;
}
case SMS: {
deliveryMethodNotificationTemplate = new SmsDeliveryMethodNotificationTemplate();
break;
}
case MOBILE_APP:
deliveryMethodNotificationTemplate = new MobileAppDeliveryMethodNotificationTemplate();
break;
default:
throw new IllegalArgumentException("Unsupported delivery method " + deliveryMethod);
}
deliveryMethodNotificationTemplate.setEnabled(true);
deliveryMethodNotificationTemplate.setBody(text);
if (deliveryMethodNotificationTemplate instanceof HasSubject) {
((HasSubject) deliveryMethodNotificationTemplate).setSubject(subject);
}
config.getDeliveryMethodsTemplates().put(deliveryMethod, deliveryMethodNotificationTemplate);
}
notificationTemplate.setConfiguration(config);
return restClient.saveNotificationTemplate(notificationTemplate);
}
private NotificationRequest submitNotificationRequest(NotificationTargetId targetId, NotificationTemplateId notificationTemplateId) {
NotificationRequestConfig config = new NotificationRequestConfig();
config.setSendingDelayInSec(0);
NotificationRequest notificationRequest = NotificationRequest.builder()
.targets(List.of(targetId).stream().map(UUIDBased::getId).collect(Collectors.toList()))
.templateId(notificationTemplateId)
.additionalConfig(config)
.build();
return restClient.saveNotificationRequest(notificationRequest);
} }
} }

2
msa/js-executor/package.json

@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"config": "^4.1.1", "config": "^4.1.1",
"express": "^5.1.0", "express": "^5.1.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.1",
"kafkajs": "^2.2.4", "kafkajs": "^2.2.4",
"long": "^5.3.2", "long": "^5.3.2",
"uuid-parse": "^1.1.0", "uuid-parse": "^1.1.0",

14
msa/js-executor/yarn.lock

@ -945,10 +945,10 @@ isarray@~1.0.0:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
js-yaml@^4.1.0: js-yaml@^4.1.1:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies: dependencies:
argparse "^2.0.1" argparse "^2.0.1"
@ -1533,9 +1533,9 @@ supports-preserve-symlinks-flag@^1.0.0:
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tar-fs@^2.0.0, tar-fs@^2.1.1: tar-fs@^2.0.0, tar-fs@^2.1.1:
version "2.1.3" version "2.1.4"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.3.tgz#fb3b8843a26b6f13a08e606f7922875eb1fbbf92" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
integrity sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg== integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
dependencies: dependencies:
chownr "^1.1.1" chownr "^1.1.1"
mkdirp-classic "^0.5.2" mkdirp-classic "^0.5.2"

2
msa/web-ui/package.json

@ -19,7 +19,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"http": "0.0.0", "http": "0.0.0",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.1",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0"
}, },

14
msa/web-ui/yarn.lock

@ -1017,10 +1017,10 @@ isarray@~1.0.0:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
js-yaml@^4.1.0: js-yaml@^4.1.1:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies: dependencies:
argparse "^2.0.1" argparse "^2.0.1"
@ -1615,9 +1615,9 @@ supports-preserve-symlinks-flag@^1.0.0:
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tar-fs@^2.0.0, tar-fs@^2.1.1: tar-fs@^2.0.0, tar-fs@^2.1.1:
version "2.1.3" version "2.1.4"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.3.tgz#fb3b8843a26b6f13a08e606f7922875eb1fbbf92" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
integrity sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg== integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
dependencies: dependencies:
chownr "^1.1.1" chownr "^1.1.1"
mkdirp-classic "^0.5.2" mkdirp-classic "^0.5.2"

14
pom.xml

@ -86,7 +86,7 @@
org.apache.kafka.common.network.NetworkReceive class in the application module. It addresses the issue https://issues.apache.org/jira/browse/KAFKA-4090. org.apache.kafka.common.network.NetworkReceive class in the application module. It addresses the issue https://issues.apache.org/jira/browse/KAFKA-4090.
Here is the source to track https://github.com/apache/kafka/tree/trunk/clients/src/main/java/org/apache/kafka/common/network --> Here is the source to track https://github.com/apache/kafka/tree/trunk/clients/src/main/java/org/apache/kafka/common/network -->
<kafka.version>3.9.1</kafka.version> <kafka.version>3.9.1</kafka.version>
<lz4.version>1.8.1</lz4.version> <!-- to fix CVE-2025-12183 introduced through kafka-clients 3.9.1 TODO: remove when kafka-clients is bumped --> <lz4.version>1.10.1</lz4.version> <!-- to fix CVE-2025-12183 and CVE-2025-66566 introduced through kafka-clients 3.9.1 TODO: remove when kafka-clients is bumped -->
<bucket4j.version>8.10.1</bucket4j.version> <bucket4j.version>8.10.1</bucket4j.version>
<antlr.version>3.5.3</antlr.version> <antlr.version>3.5.3</antlr.version>
<snakeyaml.version>2.2</snakeyaml.version> <snakeyaml.version>2.2</snakeyaml.version>
@ -102,7 +102,7 @@
<passay.version>1.6.4</passay.version> <passay.version>1.6.4</passay.version>
<ua-parser.version>1.6.1</ua-parser.version> <ua-parser.version>1.6.1</ua-parser.version>
<commons-beanutils.version>1.9.4</commons-beanutils.version> <commons-beanutils.version>1.11.0</commons-beanutils.version>
<commons-collections.version>4.4</commons-collections.version> <commons-collections.version>4.4</commons-collections.version>
<protobuf-dynamic.version>1.0.4TB</protobuf-dynamic.version> <protobuf-dynamic.version>1.0.4TB</protobuf-dynamic.version>
<wire-schema.version>3.7.1</wire-schema.version> <wire-schema.version>3.7.1</wire-schema.version>
@ -147,7 +147,7 @@
<firebase-admin.version>9.2.0</firebase-admin.version> <firebase-admin.version>9.2.0</firebase-admin.version>
<snappy.version>1.1.10.5</snappy.version> <snappy.version>1.1.10.5</snappy.version>
<rocksdbjni.version>9.10.0</rocksdbjni.version> <rocksdbjni.version>9.10.0</rocksdbjni.version>
<netty.version>4.1.125.Final</netty.version> <!-- to fix CVEs. TODO: remove when fixed in spring-boot-dependencies --> <netty.version>4.1.128.Final</netty.version> <!-- to fix CVEs. TODO: remove when fixed in spring-boot-dependencies -->
</properties> </properties>
<modules> <modules>
@ -1162,7 +1162,7 @@
</exclusions> </exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.lz4</groupId> <groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId> <artifactId>lz4-java</artifactId>
<version>${lz4.version}</version> <!-- to fix CVE introduced through kafka-clients 3.9.1 --> <version>${lz4.version}</version> <!-- to fix CVE introduced through kafka-clients 3.9.1 -->
</dependency> </dependency>
@ -1445,6 +1445,12 @@
<groupId>org.apache.cassandra</groupId> <groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId> <artifactId>cassandra-all</artifactId>
<version>${cassandra-all.version}</version> <version>${cassandra-all.version}</version>
<exclusions>
<exclusion>
<groupId>org.lz4</groupId>
<artifactId>lz4-java</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.testng</groupId> <groupId>org.testng</groupId>

42
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -146,6 +146,8 @@ import org.thingsboard.server.common.data.notification.NotificationRequestInfo;
import org.thingsboard.server.common.data.notification.NotificationRequestPreview; import org.thingsboard.server.common.data.notification.NotificationRequestPreview;
import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings;
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo; import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo;
import org.thingsboard.server.common.data.oauth2.OAuth2ClientLoginInfo; import org.thingsboard.server.common.data.oauth2.OAuth2ClientLoginInfo;
@ -2294,7 +2296,8 @@ public class RestClient implements Closeable {
HttpMethod.GET, HttpMethod.GET,
HttpEntity.EMPTY, HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<DomainInfo>>() { new ParameterizedTypeReference<PageData<DomainInfo>>() {
}).getBody(); },
params).getBody();
} }
public Optional<DomainInfo> getDomainInfoById(DomainId domainId) { public Optional<DomainInfo> getDomainInfoById(DomainId domainId) {
@ -2330,7 +2333,8 @@ public class RestClient implements Closeable {
HttpMethod.GET, HttpMethod.GET,
HttpEntity.EMPTY, HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<MobileApp>>() { new ParameterizedTypeReference<PageData<MobileApp>>() {
}).getBody(); },
params).getBody();
} }
public Optional<MobileApp> getMobileAppById(MobileAppId mobileAppId) { public Optional<MobileApp> getMobileAppById(MobileAppId mobileAppId) {
@ -2362,7 +2366,8 @@ public class RestClient implements Closeable {
HttpMethod.GET, HttpMethod.GET,
HttpEntity.EMPTY, HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<MobileAppBundleInfo>>() { new ParameterizedTypeReference<PageData<MobileAppBundleInfo>>() {
}).getBody(); },
params).getBody();
} }
public Optional<MobileAppBundle> getMobileBundleById(MobileAppBundleId mobileAppBundleId) { public Optional<MobileAppBundle> getMobileBundleById(MobileAppBundleId mobileAppBundleId) {
@ -4289,11 +4294,23 @@ public class RestClient implements Closeable {
} }
} }
public PageData<Notification> getNotifications(PageLink pageLink) { public PageData<Notification> getNotifications(Boolean unreadOnly, NotificationDeliveryMethod deliveryMethod, PageLink pageLink) {
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append(baseURL).append("/api/notifications?").append(getUrlParams(pageLink));
addPageLinkToParam(params, pageLink); addPageLinkToParam(params, pageLink);
return restTemplate.exchange(
baseURL + "/api/notifications?" + getUrlParams(pageLink), if (unreadOnly != null) {
urlBuilder.append("&unreadOnly={unreadOnly}");
params.put("unreadOnly", unreadOnly.toString());
}
if (deliveryMethod != null) {
urlBuilder.append("&deliveryMethod={deliveryMethod}");
params.put("deliveryMethod", deliveryMethod.name());
}
return restTemplate.exchange(urlBuilder.toString(),
HttpMethod.GET, HttpMethod.GET,
HttpEntity.EMPTY, HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<Notification>>() { new ParameterizedTypeReference<PageData<Notification>>() {
@ -4334,7 +4351,8 @@ public class RestClient implements Closeable {
baseURL + uri, baseURL + uri,
HttpMethod.PUT, HttpMethod.PUT,
HttpEntity.EMPTY, HttpEntity.EMPTY,
Void.class); Void.class,
params);
} }
@ -4342,7 +4360,7 @@ public class RestClient implements Closeable {
restTemplate.delete(baseURL + "/api/notification/{id}", notificationId.getId()); restTemplate.delete(baseURL + "/api/notification/{id}", notificationId.getId());
} }
public NotificationRequest createNotificationRequest(NotificationRequest notificationRequest) { public NotificationRequest saveNotificationRequest(NotificationRequest notificationRequest) {
return restTemplate.postForEntity(baseURL + "/api/notification/request", notificationRequest, NotificationRequest.class).getBody(); return restTemplate.postForEntity(baseURL + "/api/notification/request", notificationRequest, NotificationRequest.class).getBody();
} }
@ -4421,6 +4439,14 @@ public class RestClient implements Closeable {
} }
} }
public NotificationTarget saveNotificationTarget(NotificationTarget notificationTarget) {
return restTemplate.postForEntity(baseURL + "/api/notification/target", notificationTarget, NotificationTarget.class).getBody();
}
public NotificationTemplate saveNotificationTemplate(NotificationTemplate notificationTemplate) {
return restTemplate.postForEntity(baseURL + "/api/notification/template", notificationTemplate, NotificationTemplate.class).getBody();
}
public AiModel saveAiModel(AiModel aiModel) { public AiModel saveAiModel(AiModel aiModel) {
return restTemplate.postForEntity(baseURL + "/api/ai/model", aiModel, AiModel.class).getBody(); return restTemplate.postForEntity(baseURL + "/api/ai/model", aiModel, AiModel.class).getBody();
} }

2
rule-engine/rule-engine-components/pom.xml

@ -97,7 +97,7 @@
<artifactId>kafka-clients</artifactId> <artifactId>kafka-clients</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.lz4</groupId> <groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId> <artifactId>lz4-java</artifactId>
</dependency> </dependency>
<dependency> <dependency>

4
tools/pom.xml

@ -55,6 +55,10 @@
<groupId>org.apache.cassandra</groupId> <groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId> <artifactId>cassandra-all</artifactId>
</dependency> </dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency> <dependency>
<groupId>commons-io</groupId> <groupId>commons-io</groupId>
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>

66
ui-ngx/patches/@angular+common+18.2.13.patch

@ -0,0 +1,66 @@
diff --git a/node_modules/@angular/common/esm2022/http/src/xsrf.mjs b/node_modules/@angular/common/esm2022/http/src/xsrf.mjs
index da69c17..d17f6ad 100755
--- a/node_modules/@angular/common/esm2022/http/src/xsrf.mjs
+++ b/node_modules/@angular/common/esm2022/http/src/xsrf.mjs
@@ -19,6 +19,10 @@ export const XSRF_HEADER_NAME = new InjectionToken(ngDevMode ? 'XSRF_HEADER_NAME
providedIn: 'root',
factory: () => XSRF_DEFAULT_HEADER_NAME,
});
+/**
+ * Regex to match absolute URLs, including protocol-relative URLs.
+ */
+const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i;
/**
* Retrieves the current XSRF token to use with the next outgoing request.
*
@@ -69,7 +73,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImpo
args: [XSRF_COOKIE_NAME]
}] }] });
export function xsrfInterceptorFn(req, next) {
- const lcUrl = req.url.toLowerCase();
// Skip both non-mutating requests and absolute URLs.
// Non-mutating requests don't require a token, and absolute URLs require special handling
// anyway as the cookie set
@@ -77,8 +80,7 @@ export function xsrfInterceptorFn(req, next) {
if (!inject(XSRF_ENABLED) ||
req.method === 'GET' ||
req.method === 'HEAD' ||
- lcUrl.startsWith('http://') ||
- lcUrl.startsWith('https://')) {
+ ABSOLUTE_URL_REGEX.test(req.url)) {
return next(req);
}
const token = inject(HttpXsrfTokenExtractor).getToken();
diff --git a/node_modules/@angular/common/fesm2022/http.mjs b/node_modules/@angular/common/fesm2022/http.mjs
index 1655480..d1dbb38 100755
--- a/node_modules/@angular/common/fesm2022/http.mjs
+++ b/node_modules/@angular/common/fesm2022/http.mjs
@@ -2352,6 +2352,10 @@ const XSRF_HEADER_NAME = new InjectionToken(ngDevMode ? 'XSRF_HEADER_NAME' : '',
providedIn: 'root',
factory: () => XSRF_DEFAULT_HEADER_NAME,
});
+/**
+ * Regex to match absolute URLs, including protocol-relative URLs.
+ */
+const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i;
/**
* Retrieves the current XSRF token to use with the next outgoing request.
*
@@ -2402,7 +2406,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImpo
args: [XSRF_COOKIE_NAME]
}] }] });
function xsrfInterceptorFn(req, next) {
- const lcUrl = req.url.toLowerCase();
// Skip both non-mutating requests and absolute URLs.
// Non-mutating requests don't require a token, and absolute URLs require special handling
// anyway as the cookie set
@@ -2410,8 +2413,7 @@ function xsrfInterceptorFn(req, next) {
if (!inject(XSRF_ENABLED) ||
req.method === 'GET' ||
req.method === 'HEAD' ||
- lcUrl.startsWith('http://') ||
- lcUrl.startsWith('https://')) {
+ ABSOLUTE_URL_REGEX.test(req.url)) {
return next(req);
}
const token = inject(HttpXsrfTokenExtractor).getToken();

88
ui-ngx/patches/@angular+compiler+18.2.13.patch

@ -0,0 +1,88 @@
diff --git a/node_modules/@angular/compiler/fesm2022/compiler.mjs b/node_modules/@angular/compiler/fesm2022/compiler.mjs
index a00b189..260e7be 100755
--- a/node_modules/@angular/compiler/fesm2022/compiler.mjs
+++ b/node_modules/@angular/compiler/fesm2022/compiler.mjs
@@ -18631,6 +18631,7 @@ function SECURITY_SCHEMA() {
'area|ping',
'audio|src',
'a|href',
+ 'a|xlink:href',
'a|ping',
'blockquote|cite',
'body|background',
@@ -18644,6 +18645,75 @@ function SECURITY_SCHEMA() {
'track|src',
'video|poster',
'video|src',
+
+ // MathML namespace
+ // https://crsrc.org/c/third_party/blink/renderer/core/sanitizer/sanitizer.cc;l=753-768;drc=b3eb16372dcd3317d65e9e0265015e322494edcd;bpv=1;bpt=1
+ 'annotation|href',
+ 'annotation|xlink:href',
+ 'annotation-xml|href',
+ 'annotation-xml|xlink:href',
+ 'maction|href',
+ 'maction|xlink:href',
+ 'malignmark|href',
+ 'malignmark|xlink:href',
+ 'math|href',
+ 'math|xlink:href',
+ 'mroot|href',
+ 'mroot|xlink:href',
+ 'msqrt|href',
+ 'msqrt|xlink:href',
+ 'merror|href',
+ 'merror|xlink:href',
+ 'mfrac|href',
+ 'mfrac|xlink:href',
+ 'mglyph|href',
+ 'mglyph|xlink:href',
+ 'msub|href',
+ 'msub|xlink:href',
+ 'msup|href',
+ 'msup|xlink:href',
+ 'msubsup|href',
+ 'msubsup|xlink:href',
+ 'mmultiscripts|href',
+ 'mmultiscripts|xlink:href',
+ 'mprescripts|href',
+ 'mprescripts|xlink:href',
+ 'mi|href',
+ 'mi|xlink:href',
+ 'mn|href',
+ 'mn|xlink:href',
+ 'mo|href',
+ 'mo|xlink:href',
+ 'mpadded|href',
+ 'mpadded|xlink:href',
+ 'mphantom|href',
+ 'mphantom|xlink:href',
+ 'mrow|href',
+ 'mrow|xlink:href',
+ 'ms|href',
+ 'ms|xlink:href',
+ 'mspace|href',
+ 'mspace|xlink:href',
+ 'mstyle|href',
+ 'mstyle|xlink:href',
+ 'mtable|href',
+ 'mtable|xlink:href',
+ 'mtd|href',
+ 'mtd|xlink:href',
+ 'mtr|href',
+ 'mtr|xlink:href',
+ 'mtext|href',
+ 'mtext|xlink:href',
+ 'mover|href',
+ 'mover|xlink:href',
+ 'munder|href',
+ 'munder|xlink:href',
+ 'munderover|href',
+ 'munderover|xlink:href',
+ 'semantics|href',
+ 'semantics|xlink:href',
+ 'none|href',
+ 'none|xlink:href',
]);
registerContext(SecurityContext.RESOURCE_URL, [
'applet|code',

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

@ -37,6 +37,7 @@ export interface SysParamsState {
maxRelationLevelPerCfArgument: number; maxRelationLevelPerCfArgument: number;
ruleChainDebugPerTenantLimitsConfiguration?: string; ruleChainDebugPerTenantLimitsConfiguration?: string;
calculatedFieldDebugPerTenantLimitsConfiguration?: string; calculatedFieldDebugPerTenantLimitsConfiguration?: string;
intermediateAggregationIntervalInSecForCF: number;
trendzSettings: TrendzSettings; trendzSettings: TrendzSettings;
} }

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

@ -39,6 +39,7 @@ const emptyUserAuthState: AuthPayload = {
maxRelationLevelPerCfArgument: 0, maxRelationLevelPerCfArgument: 0,
maxDataPointsPerRollingArg: 0, maxDataPointsPerRollingArg: 0,
maxDebugModeDurationMinutes: 0, maxDebugModeDurationMinutes: 0,
intermediateAggregationIntervalInSecForCF: 0,
userSettings: initialUserSettings, userSettings: initialUserSettings,
trendzSettings: initialTrendzSettings trendzSettings: initialTrendzSettings
}; };

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

@ -29,10 +29,11 @@
<div class="tb-form-panel no-border no-padding"> <div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'common.general' | translate }}</div> <div class="tb-form-panel-title">{{ 'common.general' | translate }}</div>
<div class="flex items-center gap-2"> <div class="flex gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'alarm-rule.alarm-type' | translate }}</mat-label> <mat-label>{{ 'alarm-rule.alarm-type' | translate }}</mat-label>
<input matInput maxlength="255" formControlName="name" required> <input matInput maxlength="255" formControlName="name" required>
<mat-hint translate>alarm-rule.alarm-type-hint</mat-hint>
@if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { @if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) {
<mat-error> <mat-error>
@if (fieldFormGroup.get('name').hasError('required')) { @if (fieldFormGroup.get('name').hasError('required')) {
@ -48,15 +49,18 @@
<tb-entity-debug-settings-button <tb-entity-debug-settings-button
formControlName="debugSettings" formControlName="debugSettings"
entityLabel="alarm-rule.alarm-rule" entityLabel="alarm-rule.alarm-rule"
class="pt-[10px]"
[class.mb-5]="fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched" [class.mb-5]="fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched"
[entityType]="EntityType.CALCULATED_FIELD" [entityType]="EntityType.CALCULATED_FIELD"
[additionalActionConfig]="additionalDebugActionConfig" [additionalActionConfig]="additionalDebugActionConfig"
/> />
</div> </div>
@if (!data.entityId) { @if (!data.entityId) {
<div class="flex flex-col gap-4" formGroupName="entityId"> <div class="flex flex-row xs:flex-col gap-4" formGroupName="entityId">
<tb-entity-type-select appearance="outline" <tb-entity-type-select #entityTypeSelect
appearance="outline"
subscriptSizing="dynamic" subscriptSizing="dynamic"
class="flex-1"
showLabel showLabel
required required
label="{{ 'alarm-rule.target-entity-type' | translate }}" label="{{ 'alarm-rule.target-entity-type' | translate }}"
@ -65,13 +69,14 @@
formControlName="entityType"> formControlName="entityType">
</tb-entity-type-select> </tb-entity-type-select>
@if (fieldFormGroup.get('entityId.entityType').value) { @if (fieldFormGroup.get('entityId.entityType').value) {
<tb-entity-autocomplete <tb-entity-autocomplete #entityAutocompleteComponent
formControlName="id" formControlName="id"
appearance="outline" appearance="outline"
subscriptSizing="dynamic" subscriptSizing="dynamic"
[placeholder]="'action.set' | translate" class="flex-1"
[required]="true" [placeholder]="'action.set' | translate"
[entityType]="fieldFormGroup.get('entityId.entityType').value" [required]="true"
[entityType]="fieldFormGroup.get('entityId.entityType').value"
/> />
} }
</div> </div>
@ -85,8 +90,8 @@
[tenantId]="data.tenantId" [tenantId]="data.tenantId"
[ownerId]="data.ownerId" [ownerId]="data.ownerId"
[watchKeyChange]="true" [watchKeyChange]="true"
[disabledAddButton]="!fieldFormGroup.get('entityId.id').value" [disable]="!fieldFormGroup.get('entityId.id').value || !fieldFormGroup.get('name').value"
[entityName]="data.entityName"/> [entityName]="entityName"/>
</div> </div>
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.create-conditions' | translate }}</div> <div class="tb-form-panel-title">{{ 'alarm-rule.create-conditions' | translate }}</div>
@ -113,11 +118,11 @@
</button> </button>
</div> </div>
<div *ngIf="!configFormGroup.get('clearRule').value"> <div *ngIf="!configFormGroup.get('clearRule').value">
<span translate style="margin: 16px 0" <span translate class="tb-prompt flex items-center justify-center">alarm-rule.no-clear-alarm-rule</span>
class="tb-prompt flex items-center justify-center">alarm-rule.no-clear-alarm-rule</span>
</div> </div>
<div [class.!hidden]="configFormGroup.get('clearRule').value"> <div [class.!hidden]="configFormGroup.get('clearRule').value">
<button mat-stroked-button color="primary" <button mat-stroked-button color="primary"
[disabled]="!disabledClearRuleButton"
type="button" type="button"
(click)="addClearAlarmRule()"> (click)="addClearAlarmRule()">
{{ 'alarm-rule.add-clear-alarm-rule' | translate }} {{ 'alarm-rule.add-clear-alarm-rule' | translate }}
@ -137,23 +142,14 @@
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
@if (configFormGroup.get('propagate').value) { @if (configFormGroup.get('propagate').value) {
<mat-form-field floatLabel="always" class="mat-block p-4" appearance="outline" subscriptSizing="dynamic"> <tb-string-items-list appearance="outline"
<mat-label translate>alarm-rule.alarm-rule-relation-types-list</mat-label> class="flex flex-1 px-4"
<mat-chip-grid #relationTypesChipList> label="{{ 'alarm-rule.alarm-rule-relation-types-list' | translate }}"
<mat-chip-row placeholder="{{ 'alarm-rule.alarm-rule-relation-types-list' | translate }}"
*ngFor="let key of configFormGroup.get('propagateRelationTypes').value;" hint="{{ 'alarm-rule.alarm-rule-relation-types-list-hint' | translate }}"
(removed)="removeRelationType(key)"> [predefinedValues]="predefinedTypeValues"
{{key}} formControlName="propagateRelationTypes">
<mat-icon matChipRemove>close</mat-icon> </tb-string-items-list>
</mat-chip-row>
<input matInput type="text" placeholder="{{'alarm-rule.alarm-rule-relation-types-list' | translate}}"
[matChipInputFor]="relationTypesChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
matChipInputAddOnBlur
(matChipInputTokenEnd)="addRelationType($event)">
</mat-chip-grid>
<mat-hint innerHTML="{{ 'alarm-rule.alarm-rule-relation-types-list-hint' | translate }}"></mat-hint>
</mat-form-field>
} }
</div> </div>
<div class="tb-form-row"> <div class="tb-form-row">
@ -181,7 +177,7 @@
</button> </button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
(click)="add()" (click)="add()"
[disabled]="(isLoading$ | async) || fieldFormGroup.invalid || !fieldFormGroup.dirty"> [disabled]="isLoading$ | async">
{{ data.buttonTitle | translate }} {{ data.buttonTitle | translate }}
</button> </button>
</div> </div>

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

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -40,6 +40,11 @@ import {
import { deepTrim } from "@core/utils"; import { deepTrim } from "@core/utils";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { switchMap } from "rxjs/operators"; import { switchMap } from "rxjs/operators";
import { EntityTypeSelectComponent } from "@shared/components/entity/entity-type-select.component";
import { EntityAutocompleteComponent } from "@shared/components/entity/entity-autocomplete.component";
import { EntityService } from "@core/http/entity.service";
import { RelationTypes } from "@shared/models/relation.models";
import { StringItemsOption } from "@shared/components/string-items-list.component";
export interface AlarmRuleDialogData { export interface AlarmRuleDialogData {
value?: CalculatedField; value?: CalculatedField;
@ -66,7 +71,7 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
type: [CalculatedFieldType.ALARM], type: [CalculatedFieldType.ALARM],
debugSettings: [], debugSettings: [],
entityId: this.fb.group({ entityId: this.fb.group({
entityType: this.fb.control<EntityType | AliasEntityType | null>(null, Validators.required), entityType: this.fb.control<EntityType | AliasEntityType | null>(EntityType.DEVICE_PROFILE, Validators.required),
id: [null as null | string, Validators.required], id: [null as null | string, Validators.required],
}), }),
configuration: this.fb.group({ configuration: this.fb.group({
@ -93,15 +98,45 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
separatorKeysCodes = [ENTER, COMMA, SEMICOLON]; separatorKeysCodes = [ENTER, COMMA, SEMICOLON];
entityName = this.data.entityName;
disabledClearRuleButton = false;
@ViewChild('entityTypeSelect') entityTypeSelect: EntityTypeSelectComponent;
@ViewChild('entityAutocompleteComponent') entityAutocompleteComponent: EntityAutocompleteComponent;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData, @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData,
protected dialogRef: MatDialogRef<AlarmRuleDialogComponent, CalculatedField>, protected dialogRef: MatDialogRef<AlarmRuleDialogComponent, CalculatedField>,
private calculatedFieldsService: CalculatedFieldsService, private calculatedFieldsService: CalculatedFieldsService,
private entityService: EntityService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private fb: FormBuilder) { private fb: FormBuilder) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.applyDialogData(); this.applyDialogData();
this.updateRulesValidators();
this.fieldFormGroup.get('configuration.arguments').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateRulesValidators();
});
if (!this.entityName) {
this.fieldFormGroup.get('entityId.id').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((entityId) => {
if (entityId && (this.fieldFormGroup.get('entityId.entityType').value === EntityType.DEVICE_PROFILE ||
this.fieldFormGroup.get('entityId.entityType').value === EntityType.ASSET_PROFILE)) {
this.entityService.getEntity(this.fieldFormGroup.get('entityId.entityType').value as EntityType, entityId, {ignoreLoading: true, ignoreErrors: true}).subscribe(
value => {
this.entityName = value.name;
}
)
}
});
}
} }
get configFormGroup(): FormGroup { get configFormGroup(): FormGroup {
@ -169,6 +204,10 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
this.calculatedFieldsService.saveCalculatedField(alarmRule) this.calculatedFieldsService.saveCalculatedField(alarmRule)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(calculatedField => this.dialogRef.close(calculatedField)); .subscribe(calculatedField => this.dialogRef.close(calculatedField));
} else {
this.fieldFormGroup.get('name').markAsTouched();
this.entityTypeSelect.markAsTouched();
this.entityAutocompleteComponent.markAsTouched();
} }
} }
@ -191,4 +230,23 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
} }
return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, null, false); return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, null, false);
} }
private updateRulesValidators(): void {
if (Object.keys(this.arguments ?? {}).length > 0) {
this.fieldFormGroup.get('configuration.createRules').enable({emitEvent: false});
this.fieldFormGroup.get('configuration.clearRule').enable({emitEvent: false});
this.disabledClearRuleButton = true;
} else {
this.fieldFormGroup.get('configuration.createRules').disable({emitEvent: false});
this.fieldFormGroup.get('configuration.clearRule').disable({emitEvent: false});
this.disabledClearRuleButton = false;
}
}
get predefinedTypeValues(): StringItemsOption[] {
return RelationTypes.map(type => ({
name: type,
value: type
}));
}
} }

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

@ -287,7 +287,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
} }
private importCalculatedField(): void { private importCalculatedField(): void {
this.importExportService.openCalculatedFieldImportDialog() this.importExportService.openCalculatedFieldImportDialog('alarm-rule.import', 'alarm-rule.file')
.pipe( .pipe(
filter(Boolean), filter(Boolean),
switchMap(calculatedField => { switchMap(calculatedField => {

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

@ -75,14 +75,14 @@
matTooltipPosition="above" matTooltipPosition="above"
class="tb-mat-32" class="tb-mat-32"
[disabled]="!argumentsList.length" [disabled]="!argumentsList.length"
(click)="onTestScript()"> (click)="onTestScript($event)">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon> <mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button> </button>
</tb-js-func> </tb-js-func>
<div> <div>
<button mat-button mat-raised-button color="primary" <button mat-button mat-raised-button color="primary"
type="button" type="button"
(click)="onTestScript()" (click)="onTestScript($event)"
[disabled]="!argumentsList.length"> [disabled]="!argumentsList.length">
{{ 'calculated-fields.test-script-function' | translate }} {{ 'calculated-fields.test-script-function' | translate }}
</button> </button>

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

@ -41,6 +41,7 @@ import {
alarmRuleDefaultScript, alarmRuleDefaultScript,
AlarmRuleExpressionType, AlarmRuleExpressionType,
AlarmRuleFilter, AlarmRuleFilter,
areFiltersAndPredicateArgumentsValid,
filterOperationTranslationMap filterOperationTranslationMap
} from "@shared/models/alarm-rule.models"; } from "@shared/models/alarm-rule.models";
@ -137,7 +138,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
type: this.condition?.type ?? AlarmRuleConditionType.SIMPLE, type: this.condition?.type ?? AlarmRuleConditionType.SIMPLE,
unit: this.condition?.unit ?? TimeUnit.SECONDS, unit: this.condition?.unit ?? TimeUnit.SECONDS,
value: { value: {
staticValue: this.condition?.value?.staticValue, staticValue: this.condition?.value?.staticValue ?? null,
dynamicValueArgument: Object.keys(this.data.arguments).includes(this.condition?.value?.dynamicValueArgument) ? this.condition?.value?.dynamicValueArgument : null, dynamicValueArgument: Object.keys(this.data.arguments).includes(this.condition?.value?.dynamicValueArgument) ? this.condition?.value?.dynamicValueArgument : null,
}, },
count: { count: {
@ -150,7 +151,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
this.durationDynamicModeControl.patchValue(!!this.condition?.value?.dynamicValueArgument, {emitEvent: false}); this.durationDynamicModeControl.patchValue(!!this.condition?.value?.dynamicValueArgument, {emitEvent: false});
this.repeatingDynamicModeControl.patchValue(!!this.condition?.count?.dynamicValueArgument, {emitEvent: false}); this.repeatingDynamicModeControl.patchValue(!!this.condition?.count?.dynamicValueArgument, {emitEvent: false});
this.filtersValid = this.areFilterAndPredicateArgumentsValid(this.condition?.expression?.filters, this.argumentsList); this.filtersValid = areFiltersAndPredicateArgumentsValid(this.condition?.expression?.filters, this.data.arguments);
this.checkIsNoData(this.condition?.expression?.filters); this.checkIsNoData(this.condition?.expression?.filters);
this.conditionFormGroup.get('type').valueChanges.pipe( this.conditionFormGroup.get('type').valueChanges.pipe(
@ -168,7 +169,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
this.conditionFormGroup.get('expression.filters').valueChanges.pipe( this.conditionFormGroup.get('expression.filters').valueChanges.pipe(
takeUntilDestroyed() takeUntilDestroyed()
).subscribe((filters) => { ).subscribe((filters) => {
this.filtersValid = this.areFilterAndPredicateArgumentsValid(filters, this.argumentsList); this.filtersValid = areFiltersAndPredicateArgumentsValid(filters, this.data.arguments);
this.checkIsNoData(filters); this.checkIsNoData(filters);
}); });
@ -206,39 +207,6 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
} }
} }
private areFilterAndPredicateArgumentsValid(obj: any, validArguments: string[]): boolean {
const validSet = new Set(validArguments);
const filters = obj || [];
for (const filter of filters) {
if (filter.argument && !validSet.has(filter.argument)) {
return false;
}
}
function checkPredicates(predicates: any[]): boolean {
for (const p of predicates) {
if (p.value?.dynamicValueArgument) {
if (!validSet.has(p.value.dynamicValueArgument)) {
return false;
}
}
if (p.type === 'COMPLEX' && Array.isArray(p.predicates)) {
if (!checkPredicates(p.predicates)) {
return false;
}
}
}
return true;
}
for (const filter of filters) {
if (Array.isArray(filter.predicates)) {
if (!checkPredicates(filter.predicates)) {
return false;
}
}
}
return true;
}
updateExpressionTypeValidator(type: 'SIMPLE' | 'TBEL') { updateExpressionTypeValidator(type: 'SIMPLE' | 'TBEL') {
if (type === 'SIMPLE') { if (type === 'SIMPLE') {
this.conditionFormGroup.get(`expression.expression`).disable({emitEvent: false}); this.conditionFormGroup.get(`expression.expression`).disable({emitEvent: false});
@ -324,7 +292,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
if (value.dynamicValueArgument) { if (value.dynamicValueArgument) {
this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', { this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', {
attribute: `${value.dynamicValueArgument}` attribute: `${value.dynamicValueArgument}`
}); }) + ' ' + this.translate.instant(this.timeUnitTranslations.get(this.conditionFormGroup.get('unit').value)).toLowerCase();
} else { } else {
this.specText = this.translate.instant('alarm-rule.condition-during', { this.specText = this.translate.instant('alarm-rule.condition-during', {
during: duringText during: duringText
@ -355,7 +323,8 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
this.dialogRef.close(this.conditionFormGroup.value as AlarmRuleCondition); this.dialogRef.close(this.conditionFormGroup.value as AlarmRuleCondition);
} }
onTestScript() { onTestScript($event: Event) {
$event?.preventDefault();
this.data.testScript(this.conditionFormGroup.get('expression.expression').value).subscribe( this.data.testScript(this.conditionFormGroup.get('expression.expression').value).subscribe(
(expression) => { (expression) => {
this.conditionFormGroup.get('expression.expression').setValue(expression); this.conditionFormGroup.get('expression.expression').setValue(expression);

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

@ -26,13 +26,15 @@
<div class="flex items-center gap-2 justify-between"> <div class="flex items-center gap-2 justify-between">
<tb-alarm-rule-filter-text [alarmRuleExpression]="alarmRuleConditionFormGroup.get('expression').value" <tb-alarm-rule-filter-text [alarmRuleExpression]="alarmRuleConditionFormGroup.get('expression').value"
[arguments]="arguments" [arguments]="arguments"
[disabled]="disabled"
class="flex-1" class="flex-1"
[nowrap]="true" [nowrap]="true"
[specText]="specText" [specText]="specText"
required required
addFilterPrompt="{{ (isClearCondition ? 'alarm-rule.enter-alarm-rule-clear-condition-prompt' :'alarm-rule.enter-alarm-rule-condition-prompt') | translate }}"> addFilterPrompt="{{ (isClearCondition ? 'alarm-rule.enter-alarm-rule-clear-condition-prompt' :'alarm-rule.enter-alarm-rule-condition-prompt') | translate }}">
</tb-alarm-rule-filter-text> </tb-alarm-rule-filter-text>
<mat-icon [color]="conditionSet() && this.filtersArgumentsValid ? 'primary' : 'warn'" class="tb-mat-20 tb-alarm-rule-schedule-edit-icon"> <mat-icon [color]="disabled ? '' : (conditionSet() && this.filtersArgumentsValid ? 'primary' : 'warn')"
class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">
{{ conditionSet() ? 'edit' : 'add' }} {{ conditionSet() ? 'edit' : 'add' }}
</mat-icon> </mat-icon>
</div> </div>
@ -48,7 +50,10 @@
(click)="openScheduleDialog($event)"> (click)="openScheduleDialog($event)">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="tb-alarm-rule-condition-label" tbTruncateWithTooltip [innerHTML]="scheduleText"></span> <span class="tb-alarm-rule-condition-label" tbTruncateWithTooltip [innerHTML]="scheduleText"></span>
<mat-icon [color]="schedulerArgumentsValid ? 'primary' : 'warn'" class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">edit</mat-icon> <mat-icon [color]="disabled ? '' : (schedulerArgumentsValid ? 'primary' : 'warn')"
class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">
edit
</mat-icon>
</div> </div>
</button> </button>
</div> </div>

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

@ -32,7 +32,7 @@ import {
getAlarmScheduleRangeText, getAlarmScheduleRangeText,
utcTimestampToTimeOfDay utcTimestampToTimeOfDay
} from '@shared/models/device.models'; } from '@shared/models/device.models';
import { TimeUnit } from '@shared/models/time/time.models'; import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models';
import { import {
CfAlarmRuleConditionDialogComponent, CfAlarmRuleConditionDialogComponent,
CfAlarmRuleConditionDialogData CfAlarmRuleConditionDialogData
@ -42,7 +42,8 @@ import {
AlarmRuleConditionType, AlarmRuleConditionType,
AlarmRuleExpressionType, AlarmRuleExpressionType,
AlarmRuleSchedule, AlarmRuleSchedule,
AlarmRuleScheduleType AlarmRuleScheduleType,
checkPredicates
} from "@shared/models/alarm-rule.models"; } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { import {
@ -154,32 +155,17 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
return !arg || validArguments.includes(arg); return !arg || validArguments.includes(arg);
} }
private areFilterAndPredicateArgumentsValid(obj: any, validArguments: string[]): boolean { private areFilterAndPredicateArgumentsValid(obj: any, args: Record<string, CalculatedFieldArgument>): boolean {
const validSet = new Set(validArguments); const validSet = new Set(Object.keys(args));
const filters = obj?.expression?.filters || obj?.filters || []; const filters = obj?.expression?.filters || obj?.filters || [];
for (const filter of filters) { for (const filter of filters) {
if (filter.argument && !validSet.has(filter.argument)) { if (filter.argument && !validSet.has(filter.argument)) {
return false; return false;
} }
} }
function checkPredicates(predicates: any[]): boolean {
for (const p of predicates) {
if (p.value?.dynamicValueArgument) {
if (!validSet.has(p.value.dynamicValueArgument)) {
return false;
}
}
if (p.type === 'COMPLEX' && Array.isArray(p.predicates)) {
if (!checkPredicates(p.predicates)) {
return false;
}
}
}
return true;
}
for (const filter of filters) { for (const filter of filters) {
if (Array.isArray(filter.predicates)) { if (Array.isArray(filter.predicates)) {
if (!checkPredicates(filter.predicates)) { if (!checkPredicates(filter.predicates, validSet)) {
return false; return false;
} }
} }
@ -192,7 +178,7 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
} }
public validate(control: AbstractControl): ValidationErrors | null { public validate(control: AbstractControl): ValidationErrors | null {
this.filtersArgumentsValid = this.areFilterAndPredicateArgumentsValid(this.modelValue, Object.keys(this.arguments)); this.filtersArgumentsValid = this.areFilterAndPredicateArgumentsValid(this.modelValue, this.arguments);
this.schedulerArgumentsValid = this.isScheduleArgumentValid(this.modelValue, Object.keys(this.arguments)); this.schedulerArgumentsValid = this.isScheduleArgumentValid(this.modelValue, Object.keys(this.arguments));
this.onValidatorChange = () => { this.onValidatorChange = () => {
control.updateValueAndValidity({ emitEvent: true }); control.updateValueAndValidity({ emitEvent: true });
@ -265,7 +251,7 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
if (this.modelValue.value.dynamicValueArgument) { if (this.modelValue.value.dynamicValueArgument) {
this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', { this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', {
attribute: `${this.modelValue.value.dynamicValueArgument}` attribute: `${this.modelValue.value.dynamicValueArgument}`
}); }) + ' ' + this.translate.instant(timeUnitTranslationMap.get(this.modelValue.unit)).toLowerCase();
} else { } else {
this.specText = this.translate.instant('alarm-rule.condition-during', { this.specText = this.translate.instant('alarm-rule.condition-during', {
during: duringText during: duringText

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

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

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

@ -16,7 +16,7 @@
--> -->
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col gap-4">
@for (createAlarmRuleControl of createAlarmRulesFormArray().controls; track createAlarmRuleControl; let index = $index) { @for (createAlarmRuleControl of createAlarmRulesFormArray().controls; track createAlarmRuleControl; let index = $index) {
<div class="flex flex-row items-center justify-start gap-2 pb-2" [formGroup]="createAlarmRuleControl"> <div class="flex flex-row items-center justify-start gap-2 pb-2" [formGroup]="createAlarmRuleControl">
<div class="create-alarm-rule flex flex-1 flex-col gap-4" <div class="create-alarm-rule flex flex-1 flex-col gap-4"
@ -48,14 +48,14 @@
</button> </button>
</div> </div>
} }
<div *ngIf="!createAlarmRulesFormArray().controls.length && !disabled"> @if (!createAlarmRulesFormArray().controls.length) {
<span translate class="flex items-center justify-center tb-prompt required" style="margin: 16px 0"> <span translate class="flex items-center justify-center tb-prompt required" [class.required]="!disabled">
alarm-rule.add-create-alarm-rule-prompt alarm-rule.add-create-alarm-rule-prompt
</span> </span>
</div> }
<div *ngIf="!disabled"> <div>
<button mat-stroked-button color="primary" <button mat-stroked-button color="primary"
[disabled]="createAlarmRulesFormArray().controls.length > 4" [disabled]="createAlarmRulesFormArray().controls.length > 4 || disabled"
type="button" type="button"
(click)="addCreateAlarmRule()"> (click)="addCreateAlarmRule()">
{{ 'alarm-rule.add-create-alarm-rule' | translate }} {{ 'alarm-rule.add-create-alarm-rule' | translate }}

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

@ -27,12 +27,12 @@
import { import {
AlarmRuleFilter, AlarmRuleFilter,
AlarmRuleFilterPredicate, AlarmRuleFilterPredicate,
filterOperationTranslationMap filterOperationTranslationMap,
isPredicateArgumentsValid
} from "@shared/models/alarm-rule.models"; } from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { FormControlsFrom } from "@shared/models/tenant.model"; import { FormControlsFrom } from "@shared/models/tenant.model";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { isDefinedAndNotNull } from "@core/utils";
export interface AlarmRuleFilterDialogData { export interface AlarmRuleFilterDialogData {
filter: AlarmRuleFilter; filter: AlarmRuleFilter;
@ -87,11 +87,11 @@ export class AlarmRuleFilterDialogComponent extends DialogComponent<AlarmRuleFil
} }
); );
this.predicatesValid = this.isPredicateArgumentsValid(this.data.filter.predicates); this.predicatesValid = isPredicateArgumentsValid(this.data.filter.predicates, this.arguments);
this.filterFormGroup.get('predicates').valueChanges.pipe( this.filterFormGroup.get('predicates').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(predicates => { ).subscribe(predicates => {
this.predicatesValid = this.isPredicateArgumentsValid(predicates); this.predicatesValid = isPredicateArgumentsValid(predicates, this.arguments);
}); });
this.filterFormGroup.get('valueType').valueChanges.pipe( this.filterFormGroup.get('valueType').valueChanges.pipe(
@ -116,31 +116,6 @@ export class AlarmRuleFilterDialogComponent extends DialogComponent<AlarmRuleFil
}); });
} }
private isPredicateArgumentsValid(predicates: any): boolean {
const validSet = new Set(Object.keys(this.data.arguments));
function checkPredicates(predicates: any[]): boolean {
for (const p of predicates) {
if (isDefinedAndNotNull(p.value?.dynamicValueArgument)) {
if (!validSet.has(p.value.dynamicValueArgument)) {
return false;
}
}
if (p.type === 'COMPLEX' && Array.isArray(p.predicates)) {
if (!checkPredicates(p.predicates)) {
return false;
}
}
}
return true;
}
if (Array.isArray(predicates)) {
if (!checkPredicates(predicates)) {
return false;
}
}
return true;
}
argumentInUse(argument: string): boolean { argumentInUse(argument: string): boolean {
return this.data.usedArguments.includes(argument); return this.data.usedArguments.includes(argument);
} }

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

@ -15,69 +15,68 @@
limitations under the License. limitations under the License.
--> -->
<section class="tb-form-panel no-padding no-gap stroked mb-4" [formGroup]="filterListFormGroup"> @if (filtersFormArray.length) {
<div class="flex flex-row"> <section class="tb-form-panel no-padding no-gap stroked mb-4" [formGroup]="filterListFormGroup">
<span class="max-w-8% flex-full"></span> <div class="flex flex-row">
<div class="flex max-w-92% flex-full flex-row items-center justify-start"> <span class="max-w-8% flex-full"></span>
<label translate class="filter-title flex-1">alarm-rule.argument-name</label> <div class="flex max-w-92% flex-full flex-row items-center justify-start">
<label translate class="filter-title flex-1">alarm-rule.value-type</label> <label translate class="filter-title flex-1">alarm-rule.argument-name</label>
<span class="min-w-24">&nbsp;</span> <label translate class="filter-title flex-1">alarm-rule.value-type</label>
<span class="min-w-24">&nbsp;</span>
</div>
</div> </div>
</div> <mat-divider></mat-divider>
<mat-divider></mat-divider> <div class="filter-list">
<div class="filter-list"> @for (filterControl of filtersFormArray.controls; track filterControl; let index = $index) {
@for (filterControl of filtersFormArray.controls; track filterControl; let index = $index) { <div class="flex flex-row items-stretch justify-start"
<div class="flex flex-row items-stretch justify-start" formArrayName="filters"
formArrayName="filters" [class.filter-list-divider]="index">
[class.filter-list-divider]="index"> <div class="filters-operation max-w-8% flex-full">
<div class="filters-operation max-w-8% flex-full"> @if ($index) {
@if ($index) { <div class="filters-operation-container">
<div class="filters-operation-container"> <span class="filters-operation-label">{{ complexOperationTranslationMap.get(operation) | translate }}</span>
<span class="filters-operation-label">{{ complexOperationTranslationMap.get(operation) | translate }}</span> </div>
}
</div>
<div class="flex max-w-92% flex-full flex-col">
<div class="flex flex-row items-center justify-start">
<div class="flex-1 filters-text">{{ filterControl.value?.argument }}</div>
<div class="flex-1 filters-text">{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.valueType) | translate }}</div>
<button mat-icon-button color="primary" class="tb-budge-button"
type="button"
(click)="editFilter(index)"
matTooltip="{{ 'alarm-rule.edit-filter' | translate }}"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="areFilterAndPredicateArgumentsValid(filterControl.value, arguments)"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*"
>
edit
</mat-icon>
</button>
<button mat-icon-button color="primary"
type="button"
(click)="removeFilter(index)"
matTooltip="{{ 'alarm-rule.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div> </div>
}
</div>
<div class="flex max-w-92% flex-full flex-col">
<div class="flex flex-row items-center justify-start">
<div class="flex-1 filters-text">{{ filterControl.value?.argument }}</div>
<div class="flex-1 filters-text">{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.valueType) | translate }}</div>
<button mat-icon-button color="primary" class="tb-budge-button"
type="button"
(click)="editFilter(index)"
matTooltip="{{ 'alarm-rule.edit-filter' | translate }}"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="areFilterAndPredicateArgumentsValid(filterControl.value)"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*"
>
edit
</mat-icon>
</button>
<button mat-icon-button color="primary"
type="button"
(click)="removeFilter(index)"
matTooltip="{{ 'alarm-rule.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div> </div>
@if (index) {
<mat-divider></mat-divider>
}
</div> </div>
@if (index) { }
<mat-divider></mat-divider> </div>
} </section>
</div> } @else {
} <span class="no-data-found tb-prompt flex items-center justify-center" [class.required]="!disabled" translate>alarm-rule.no-filter</span>
@if (!filtersFormArray.length) { }
<span class="no-data-found tb-prompt flex items-center justify-center" translate>
alarm-rule.no-filter
</span>
<tb-error noMargin [error]="'alarm-rule.filter-required' | translate" class="flex h-9 items-center pl-3"/>
}
</div>
</section>
<button mat-button mat-stroked-button color="primary" <button mat-button mat-stroked-button color="primary"
[class.!hidden]="disabled"
(click)="addFilter()" (click)="addFilter()"
type="button"> type="button">
{{ 'alarm-rule.add-filter' | translate }} {{ 'alarm-rule.add-filter' | translate }}

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

@ -38,7 +38,11 @@ import {
AlarmRuleFilterDialogComponent, AlarmRuleFilterDialogComponent,
AlarmRuleFilterDialogData AlarmRuleFilterDialogData
} from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; } from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component";
import { AlarmRuleFilter, FilterPredicateTypeTranslationMap } from "@shared/models/alarm-rule.models"; import {
AlarmRuleFilter,
areFilterAndPredicateArgumentsValid,
FilterPredicateTypeTranslationMap
} from "@shared/models/alarm-rule.models";
import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@ -71,6 +75,10 @@ export class AlarmRuleFilterListComponent implements ControlValueAccessor, Valid
filters: this.fb.array([]) filters: this.fb.array([])
}); });
disabled = false;
areFilterAndPredicateArgumentsValid = areFilterAndPredicateArgumentsValid;
complexOperationTranslationMap = complexOperationTranslationMap; complexOperationTranslationMap = complexOperationTranslationMap;
FilterPredicateTypeTranslationMap = FilterPredicateTypeTranslationMap FilterPredicateTypeTranslationMap = FilterPredicateTypeTranslationMap
@ -102,37 +110,8 @@ export class AlarmRuleFilterListComponent implements ControlValueAccessor, Valid
}; };
} }
public areFilterAndPredicateArgumentsValid(obj: any): boolean {
const validSet = new Set(Object.keys(this.arguments));
const filter = obj || [];
if (filter.argument && !validSet.has(filter.argument)) {
return false;
}
function checkPredicates(predicates: any[]): boolean {
for (const p of predicates) {
if (p.value?.dynamicValueArgument) {
if (!validSet.has(p.value.dynamicValueArgument)) {
return false;
}
}
if (p.type === 'COMPLEX' && Array.isArray(p.predicates)) {
if (!checkPredicates(p.predicates)) {
return false;
}
}
}
return true;
}
if (Array.isArray(filter.predicates)) {
if (!checkPredicates(filter.predicates)) {
return false;
}
}
return true;
}
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) { if (isDisabled) {
this.filterListFormGroup.disable({emitEvent: false}); this.filterListFormGroup.disable({emitEvent: false});
} else { } else {
@ -206,5 +185,4 @@ export class AlarmRuleFilterListComponent implements ControlValueAccessor, Valid
const filters = this.filterListFormGroup.value.filters as Array<AlarmRuleFilter>; const filters = this.filterListFormGroup.value.filters as Array<AlarmRuleFilter>;
this.propagateChange(filters); this.propagateChange(filters);
} }
} }

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

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

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

@ -150,7 +150,7 @@ export class AlarmRuleFilterPredicateNoDataValueComponent implements ControlValu
} }
writeValue(predicateValue: NoDataAlarmRuleFilterPredicate): void { writeValue(predicateValue: NoDataAlarmRuleFilterPredicate): void {
if (isDefinedAndNotNull(predicateValue.duration.dynamicValueArgument)) { if (isDefinedAndNotNull(predicateValue?.duration?.dynamicValueArgument)) {
const availableArgument = this.argumentsList.filter(arg => arg !== this.argumentInUse); const availableArgument = this.argumentsList.filter(arg => arg !== this.argumentInUse);
if (!availableArgument.includes(predicateValue.duration.dynamicValueArgument)) { if (!availableArgument.includes(predicateValue.duration.dynamicValueArgument)) {
predicateValue.duration.dynamicValueArgument = ''; predicateValue.duration.dynamicValueArgument = '';

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

@ -166,7 +166,7 @@ export class AlarmRuleFilterPredicateValueComponent implements ControlValueAcces
} }
writeValue(predicateValue: AlarmRuleValue<string | number | boolean>): void { writeValue(predicateValue: AlarmRuleValue<string | number | boolean>): void {
if (isDefinedAndNotNull(predicateValue.dynamicValueArgument)) { if (isDefinedAndNotNull(predicateValue?.dynamicValueArgument)) {
const availableArgument = this.argumentsList.filter(arg => arg !== this.argumentInUse); const availableArgument = this.argumentsList.filter(arg => arg !== this.argumentInUse);
if (!availableArgument.includes(predicateValue.dynamicValueArgument)) { if (!availableArgument.includes(predicateValue.dynamicValueArgument)) {
predicateValue.dynamicValueArgument = ''; predicateValue.dynamicValueArgument = '';

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

@ -70,20 +70,17 @@
</div> </div>
} }
} }
@if (filterPredicateFormGroup.get('operation').value === stringOperation.NO_DATA) { <tb-alarm-rule-filter-predicate-no-data-value class="flex-full" [class.!hidden]="!(filterPredicateFormGroup.get('operation').value === stringOperation.NO_DATA)"
<tb-alarm-rule-filter-predicate-no-data-value class="flex-full" [arguments]="arguments"
[arguments]="arguments" [argumentInUse]="argumentInUse"
[argumentInUse]="argumentInUse" [valueType]="valueType"
[valueType]="valueType" formControlName="duration">
formControlName="duration"> </tb-alarm-rule-filter-predicate-no-data-value>
</tb-alarm-rule-filter-predicate-no-data-value> <tb-alarm-rule-filter-predicate-value class="flex-full" [class.!hidden]="!(filterPredicateFormGroup.get('operation').value !== stringOperation.NO_DATA && type !== filterPredicateType.COMPLEX)"
} @else if (type !== filterPredicateType.COMPLEX) { [arguments]="arguments"
<tb-alarm-rule-filter-predicate-value class="flex-full" [argumentInUse]="argumentInUse"
[arguments]="arguments" [valueType]="valueType"
[argumentInUse]="argumentInUse" formControlName="value">
[valueType]="valueType" </tb-alarm-rule-filter-predicate-value>
formControlName="value">
</tb-alarm-rule-filter-predicate-value>
}
</div> </div>
</div> </div>

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

@ -34,6 +34,7 @@ import {
alarmRuleNumericOperationTranslationMap, alarmRuleNumericOperationTranslationMap,
AlarmRuleStringOperation, AlarmRuleStringOperation,
alarmRuleStringOperationTranslationMap, alarmRuleStringOperationTranslationMap,
checkPredicates,
ComplexAlarmRuleFilterPredicate ComplexAlarmRuleFilterPredicate
} from "@shared/models/alarm-rule.models"; } from "@shared/models/alarm-rule.models";
import { MatDialog } from "@angular/material/dialog"; import { MatDialog } from "@angular/material/dialog";
@ -111,6 +112,18 @@ export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor,
this.updateModel(); this.updateModel();
}); });
this.filterPredicateFormGroup.get('operation').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
if (value === 'NO_DATA') {
this.filterPredicateFormGroup.get('duration').enable({emitEvent: false});
this.filterPredicateFormGroup.get('value').disable({emitEvent: false});
} else {
this.filterPredicateFormGroup.get('duration').disable({emitEvent: false});
this.filterPredicateFormGroup.get('value').enable({emitEvent: false});
}
})
this.filterPredicateFormGroup.get('predicates').valueChanges.pipe( this.filterPredicateFormGroup.get('predicates').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(predicates => { ).subscribe(predicates => {
@ -140,25 +153,10 @@ export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor,
} }
} }
private isPredicateArgumentsValid(predicates: any): boolean { private isPredicateArgumentsValid(predicates: AlarmRuleFilterPredicate[]): boolean {
const validSet = new Set(Object.keys(this.arguments)); const validSet = new Set(Object.keys(this.arguments));
function checkPredicates(predicates: any[]): boolean {
for (const p of predicates) {
if (p.value?.dynamicValueArgument) {
if (!validSet.has(p.value.dynamicValueArgument)) {
return false;
}
}
if (p.type === 'COMPLEX' && Array.isArray(p.predicates)) {
if (!checkPredicates(p.predicates)) {
return false;
}
}
}
return true;
}
if (Array.isArray(predicates)) { if (Array.isArray(predicates)) {
if (!checkPredicates(predicates)) { if (!checkPredicates(predicates, validSet)) {
return false; return false;
} }
} }
@ -172,8 +170,12 @@ export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor,
} }
if (predicate.type === AlarmRuleFilterPredicateType.NO_DATA) { if (predicate.type === AlarmRuleFilterPredicateType.NO_DATA) {
this.type = AlarmRuleFilterPredicateType[this.valueType]; this.type = AlarmRuleFilterPredicateType[this.valueType];
this.filterPredicateFormGroup.get('duration').enable({emitEvent: false});
this.filterPredicateFormGroup.get('value').disable({emitEvent: false});
this.filterPredicateFormGroup.patchValue({operation: 'NO_DATA', duration: predicate}, {emitEvent: false}); this.filterPredicateFormGroup.patchValue({operation: 'NO_DATA', duration: predicate}, {emitEvent: false});
} else { } else {
this.filterPredicateFormGroup.get('duration').disable({emitEvent: false});
this.filterPredicateFormGroup.get('value').enable({emitEvent: false});
this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false}); this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false});
} }
} }
@ -183,30 +185,6 @@ export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor,
if (predicate.operation === 'NO_DATA') { if (predicate.operation === 'NO_DATA') {
this.propagateChange(predicate.duration); this.propagateChange(predicate.duration);
} else { } else {
if (!predicate.value) {
switch (this.valueType) {
case EntityKeyValueType.STRING:
predicate.value = {
staticValue: ''
};
break;
case EntityKeyValueType.NUMERIC:
predicate.value = {
staticValue: 0
};
break;
case EntityKeyValueType.DATE_TIME:
predicate.value = {
staticValue: Date.now()
};
break;
case EntityKeyValueType.BOOLEAN:
predicate.value = {
staticValue: false
};
break;
}
}
this.propagateChange({type: this.type, ...predicate}); this.propagateChange({type: this.type, ...predicate});
} }
} }

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

@ -18,6 +18,7 @@
<div class="tb-filter-text" <div class="tb-filter-text"
tbTruncateWithTooltip tbTruncateWithTooltip
[class.required]="isRequired" [class.required]="isRequired"
[class.disabled]="disabled"
[class.nowrap]="nowrap" [class.nowrap]="nowrap"
[class.wrap]="!nowrap" [class.wrap]="!nowrap"
[innerHTML]="filterText"> [innerHTML]="filterText">

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

@ -23,6 +23,9 @@
color: #f44336; color: #f44336;
padding: 0; padding: 0;
} }
&.disabled {
color: rgba(0,0,0,0.38);
}
&.nowrap { &.nowrap {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;

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

@ -63,6 +63,10 @@ export class AlarmRuleFilterTextComponent {
@Input() @Input()
arguments: Record<string, CalculatedFieldArgument>; arguments: Record<string, CalculatedFieldArgument>;
@Input()
@coerceBoolean()
disabled = false;
private alarmRuleExpressionValue: AlarmRuleExpression; private alarmRuleExpressionValue: AlarmRuleExpression;
get alarmRuleExpression(): AlarmRuleExpression { get alarmRuleExpression(): AlarmRuleExpression {
return this.alarmRuleExpressionValue; return this.alarmRuleExpressionValue;

22
ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html

@ -30,16 +30,18 @@
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div> <div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content> <div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col gap-2"> <fieldset [disabled]="isLoading$ | async" class="flex flex-col gap-2">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mat-block tb-inline-field"> <tb-string-autocomplete
<mat-label translate>attribute.key</mat-label> panelWidth=""
<input matInput formControlName="key" required> additionalClass="mat-block tb-inline-field tb-suffix-absolute tb-suffix-show-on-hover"
<mat-error *ngIf="attributeFormGroup.get('key').hasError('required')"> required
{{ (isTelemetry ? 'attribute.telemetry-key-required' : 'attribute.key-required') | translate }} [label]="'attribute.key' | translate"
</mat-error> subscriptSizing="dynamic"
<mat-error *ngIf="attributeFormGroup.get('key').hasError('maxlength')"> appearance="outline"
{{ 'attribute.key-max-length' | translate }} [fetchOptionsFn]="isTelemetry && fetchOptions.bind(this)"
</mat-error> [errorMessages]="attributeErrorMessages"
</mat-form-field> [controlValidators]="keyValidators"
formControlName="key">
</tb-string-autocomplete>
<tb-value-input <tb-value-input
formControlName="value" formControlName="value"
[requiredText]="isTelemetry ? 'attribute.telemetry-value-required' : 'attribute.value-required'"> [requiredText]="isTelemetry ? 'attribute.telemetry-value-required' : 'attribute.value-required'">

33
ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts

@ -23,13 +23,23 @@ import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Valida
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component'; import { DialogComponent } from '@app/shared/components/dialog.component';
import { AttributeData, AttributeScope, LatestTelemetry, TelemetryType } from '@shared/models/telemetry/telemetry.models'; import {
AttributeData,
AttributeScope,
LatestTelemetry,
TelemetryType
} from '@shared/models/telemetry/telemetry.models';
import { AttributeService } from '@core/http/attribute.service'; import { AttributeService } from '@core/http/attribute.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AttributeDatasource } from '@home/models/datasource/attribute-datasource';
import { map } from 'rxjs/operators';
import { ErrorMessageConfig } from '@shared/components/string-autocomplete.component';
import { TranslateService } from '@ngx-translate/core';
export interface AddAttributeDialogData { export interface AddAttributeDialogData {
entityId: EntityId; entityId: EntityId;
attributeScope: TelemetryType; attributeScope: TelemetryType;
datasource?: AttributeDatasource;
} }
@Component({ @Component({
@ -47,19 +57,22 @@ export class AddAttributeDialogComponent extends DialogComponent<AddAttributeDia
isTelemetry = false; isTelemetry = false;
keyValidators = [Validators.required, Validators.maxLength(255)];
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AddAttributeDialogData, @Inject(MAT_DIALOG_DATA) public data: AddAttributeDialogData,
private attributeService: AttributeService, private attributeService: AttributeService,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, @SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<AddAttributeDialogComponent, boolean>, public dialogRef: MatDialogRef<AddAttributeDialogComponent, boolean>,
public fb: FormBuilder) { public fb: FormBuilder,
private translate: TranslateService) {
super(store, router, dialogRef); super(store, router, dialogRef);
} }
ngOnInit(): void { ngOnInit(): void {
this.attributeFormGroup = this.fb.group({ this.attributeFormGroup = this.fb.group({
key: ['', [Validators.required, Validators.maxLength(255)]], key: ['', this.keyValidators],
value: [null, [Validators.required]] value: [null, [Validators.required]]
}); });
this.isTelemetry = this.data.attributeScope === LatestTelemetry.LATEST_TELEMETRY; this.isTelemetry = this.data.attributeScope === LatestTelemetry.LATEST_TELEMETRY;
@ -97,4 +110,18 @@ export class AddAttributeDialogComponent extends DialogComponent<AddAttributeDia
} }
task.subscribe(() => this.dialogRef.close(true)); task.subscribe(() => this.dialogRef.close(true));
} }
fetchOptions(searchText: string): Observable<Array<string>> {
const search = searchText ? searchText?.toLowerCase() : '';
return this.data.datasource?.getAllAttributes(this.data.entityId,this.data.attributeScope).pipe(
map(attributes => attributes?.filter(attribute => attribute.key.toLowerCase().includes(search)).map(a => a.key)),
)
}
get attributeErrorMessages(): ErrorMessageConfig {
return {
required: this.translate.instant(this.isTelemetry ? 'attribute.telemetry-key-required' : 'attribute.key-required'),
maxlength: this.translate.instant('attribute.key-max-length')
}
}
} }

14
ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts

@ -317,13 +317,19 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
const data: AddAttributeDialogData = {
entityId: this.entityIdValue,
attributeScope: this.attributeScope,
};
if(this.attributeScope === LatestTelemetry.LATEST_TELEMETRY) {
data.datasource = this.dataSource;
}
this.dialog.open<AddAttributeDialogComponent, AddAttributeDialogData, boolean>(AddAttributeDialogComponent, { this.dialog.open<AddAttributeDialogComponent, AddAttributeDialogData, boolean>(AddAttributeDialogComponent, {
disableClose: true, disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: { data
entityId: this.entityIdValue,
attributeScope: this.attributeScope
}
}).afterClosed().subscribe( }).afterClosed().subscribe(
(res) => { (res) => {
if (res) { if (res) {

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

@ -15,8 +15,10 @@
limitations under the License. limitations under the License.
--> -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-4">
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText"> <div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col"
[class.arguments-table-with-error]="errorText"
[class.!hidden]="!(dataSource.isEmpty() | async) === false">
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort <table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> [matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'"> <ng-container [matColumnDef]="'name'">
@ -94,6 +96,7 @@
<button type="button" <button type="button"
mat-icon-button mat-icon-button
#button #button
[disabled]="disable"
(click)="manageArgument($event, button, argument)" (click)="manageArgument($event, button, argument)"
[matTooltip]="'action.edit' | translate" [matTooltip]="'action.edit' | translate"
matTooltipPosition="above"> matTooltipPosition="above">
@ -119,14 +122,15 @@
<mat-header-row class="mat-row-select" *matHeaderRowDef="displayColumns"></mat-header-row> <mat-header-row class="mat-row-select" *matHeaderRowDef="displayColumns"></mat-header-row>
<mat-row *matRowDef="let argument; columns: displayColumns"></mat-row> <mat-row *matRowDef="let argument; columns: displayColumns"></mat-row>
</table> </table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center min-h-14">
{{ 'calculated-fields.no-arguments' | translate }}
</div>
@if (errorText || (dataSource.isEmpty() | async)) { @if (errorText || (dataSource.isEmpty() | async)) {
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/> <tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/>
} }
</div> </div>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
[class.required]="!disable"
class="tb-prompt flex flex-1 items-end justify-center tb-prompt required">
{{ 'calculated-fields.no-arguments' | translate }}
</div>
<div class="flex h-9 justify-between"> <div class="flex h-9 justify-between">
<button <button
type="button" type="button"
@ -134,7 +138,7 @@
color="primary" color="primary"
#button #button
(click)="manageArgument($event, button)" (click)="manageArgument($event, button)"
[disabled]="maxArgumentsPerCF > 0 && argumentsFormArray.length >= maxArgumentsPerCF || disabledAddButton" [disabled]="maxArgumentsPerCF > 0 && argumentsFormArray.length >= maxArgumentsPerCF || disable"
> >
{{ 'calculated-fields.add-argument' | translate }} {{ 'calculated-fields.add-argument' | translate }}
</button> </button>

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

@ -16,6 +16,7 @@
import { import {
AfterViewInit, AfterViewInit,
booleanAttribute,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
@ -87,7 +88,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
@Input() entityName: string; @Input() entityName: string;
@Input() ownerId: EntityId; @Input() ownerId: EntityId;
@Input() isScript: boolean; @Input() isScript: boolean;
@Input() disabledAddButton = false; @Input({transform: booleanAttribute}) disable = false;
@Input() watchKeyChange = false; @Input() watchKeyChange = false;
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
@ -220,8 +221,6 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling';
} else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
} else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty';
} else { } else {
this.errorText = ''; this.errorText = '';
} }

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

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div [formGroup]="fieldFormGroup" class="calculated-field-dialog-container flex size-full max-w-4xl flex-col"> <div [formGroup]="fieldFormGroup" class="calculated-field-dialog-container flex max-w-4xl flex-col">
<mat-toolbar color="primary"> <mat-toolbar color="primary">
<h2>{{ 'entity.type-calculated-field' | translate}}</h2> <h2>{{ 'entity.type-calculated-field' | translate}}</h2>
<span class="flex-1"></span> <span class="flex-1"></span>

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

@ -124,6 +124,15 @@
</ng-container> </ng-container>
} }
</div> </div>
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide flex" formControlName="produceIntermediateResult">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.produce-intermediate-result-hint' | translate: {
time: (intermediateAggregationIntervalInSecForCF * 1000 | milliSecondsToTimeString )
} }}">
{{ 'calculated-fields.entity-aggregation.produce-intermediate-result' | translate }}
</div>
</mat-slide-toggle>
</div>
</div> </div>
<tb-calculate-field-output formControlName="output" <tb-calculate-field-output formControlName="output"
containerInputClass="grid items-start grid-cols-2 gap-3 xs:grid-cols-1" containerInputClass="grid items-start grid-cols-2 gap-3 xs:grid-cols-1"

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

@ -37,7 +37,7 @@ import {
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { filter, map } from 'rxjs/operators'; import { filter, map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, YEAR } from '@shared/models/time/time.models'; import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, WEEK, YEAR } from '@shared/models/time/time.models';
import { deepClone, isDefinedAndNotNull } from '@core/utils'; import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@ -88,6 +88,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
@Input() testScript: (expression?: string) => Observable<string>; @Input() testScript: (expression?: string) => Observable<string>;
readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF; readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF;
readonly intermediateAggregationIntervalInSecForCF = getCurrentAuthState(this.store).intermediateAggregationIntervalInSecForCF;
readonly DayInSec = DAY / SECOND; readonly DayInSec = DAY / SECOND;
entityAggregationConfiguration = this.fb.group({ entityAggregationConfiguration = this.fb.group({
@ -104,6 +105,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
watermark: this.fb.group({ watermark: this.fb.group({
duration: [HOUR/SECOND, Validators.required], duration: [HOUR/SECOND, Validators.required],
}), }),
produceIntermediateResult: [false],
output: this.fb.control<CalculatedFieldOutput>(defaultCalculatedFieldOutput), output: this.fb.control<CalculatedFieldOutput>(defaultCalculatedFieldOutput),
}); });
@ -153,6 +155,15 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.updatedOffsetHint(); this.updatedOffsetHint();
}); });
merge(
this.entityAggregationConfiguration.get('interval.type').valueChanges,
this.entityAggregationConfiguration.get('interval.durationSec').valueChanges
).pipe(
takeUntilDestroyed()
).subscribe(() => {
this.checkProduceIntermediate();
});
this.entityAggregationConfiguration.valueChanges.pipe( this.entityAggregationConfiguration.valueChanges.pipe(
takeUntilDestroyed() takeUntilDestroyed()
).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => { ).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => {
@ -174,6 +185,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
this.checkProduceIntermediate();
this.updatedOffsetHint(); this.updatedOffsetHint();
setTimeout(() => { setTimeout(() => {
this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
@ -194,6 +206,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
this.checkProduceIntermediate();
} }
} }
@ -255,16 +268,49 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
} }
} }
private checkProduceIntermediate() {
const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType;
let durationSec = 0;
switch (intervalType) {
case AggIntervalType.CUSTOM:
durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value;
break
case AggIntervalType.HOUR:
durationSec = HOUR / SECOND;
break
case AggIntervalType.DAY:
durationSec = DAY / SECOND;
break
case AggIntervalType.WEEK:
case AggIntervalType.WEEK_SUN_SAT:
durationSec = WEEK / SECOND;
break
case AggIntervalType.MONTH:
durationSec = AVG_MONTH / SECOND;
break
case AggIntervalType.QUARTER:
durationSec = AVG_QUARTER / SECOND;
break
case AggIntervalType.YEAR:
durationSec = YEAR / SECOND;
break
}
if (durationSec > this.intermediateAggregationIntervalInSecForCF) {
this.entityAggregationConfiguration.get('produceIntermediateResult').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('produceIntermediateResult').disable({emitEvent: false});
}
}
private updatedOffsetHint(): void { private updatedOffsetHint(): void {
const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value; const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value;
const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType; const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType;
const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value; const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value;
const offsetCategory = this.getTimeCategory(offset); const offsetCategory = this.getTimeCategory(offset);
const now = _moment.utc(); const now = _moment.utc();
let interval: string = ''; let interval: string;
if (intervalType === AggIntervalType.CUSTOM) { if (intervalType === AggIntervalType.CUSTOM) {
const durationSecCategory = this.getTimeCategory(durationSec); const formatString = this.getCustomFormatString(offsetCategory, this.getTimeCategory(durationSec));
const formatString = this.getCustomFormatString(offsetCategory, durationSecCategory);
const intervals: string[] = []; const intervals: string[] = [];
let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND; let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND;
now.startOf('year').add(offset, 'seconds'); now.startOf('year').add(offset, 'seconds');

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

@ -16,7 +16,9 @@
--> -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText"> <div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col"
[class.arguments-table-with-error]="errorText"
[class.!hidden]="!(dataSource.isEmpty() | async) === false">
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort <table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> [matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'"> <ng-container [matColumnDef]="'name'">
@ -121,14 +123,14 @@
*matHeaderRowDef="['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-header-row> *matHeaderRowDef="['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-header-row>
<mat-row *matRowDef="let argument; columns: ['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-row> <mat-row *matRowDef="let argument; columns: ['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-row>
</table> </table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center">
{{ 'calculated-fields.no-zone-configured' | translate }}
</div>
@if (errorText) { @if (errorText) {
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/> <tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/>
} }
</div> </div>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center tb-prompt required">
{{ 'calculated-fields.no-zone-configured' | translate }}
</div>
<div class="flex h-9 justify-between"> <div class="flex h-9 justify-between">
<button type="button" <button type="button"
mat-stroked-button mat-stroked-button

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

@ -195,8 +195,6 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro
private updateErrorText(): void { private updateErrorText(): void {
if (this.zoneGroupsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { if (this.zoneGroupsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.geofencing-entity-not-found'; this.errorText = 'calculated-fields.hint.geofencing-entity-not-found';
} else if (!this.zoneGroupsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.geofencing-empty';
} else { } else {
this.errorText = ''; this.errorText = '';
} }

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

@ -16,7 +16,9 @@
--> -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText"> <div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col"
[class.arguments-table-with-error]="errorText"
[class.!hidden]="!(dataSource.isEmpty() | async) === false">
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort <table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> [matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'"> <ng-container [matColumnDef]="'name'">
@ -98,13 +100,10 @@
<mat-header-row class="mat-row-select" *matHeaderRowDef=displayColumns></mat-header-row> <mat-header-row class="mat-row-select" *matHeaderRowDef=displayColumns></mat-header-row>
<mat-row *matRowDef="let argument; columns: displayColumns"></mat-row> <mat-row *matRowDef="let argument; columns: displayColumns"></mat-row>
</table> </table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false" </div>
class="tb-prompt flex flex-1 items-end justify-center"> <div [class.!hidden]="(dataSource.isEmpty() | async) === false"
{{ 'calculated-fields.metrics.no-metrics-configured' | translate }} class="tb-prompt flex flex-1 items-end justify-center required">
</div> {{ 'calculated-fields.metrics.no-metrics-configured' | translate }}
@if (errorText) {
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/>
}
</div> </div>
<div class="flex h-9 justify-between"> <div class="flex h-9 justify-between">
<button type="button" <button type="button"

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

@ -72,12 +72,14 @@
</tb-time-unit-input> </tb-time-unit-input>
</div> </div>
<tb-calculate-field-output formControlName="output" [entityId]="entityId" simpleMode hiddenName> <tb-calculate-field-output formControlName="output" [entityId]="entityId" simpleMode hiddenName>
<div class="tb-form-row simpleMode flex-1"> @if (relatedAggregationConfiguration.get('output').value?.type === OutputType.Timeseries) {
<mat-slide-toggle class="mat-slide" formControlName="useLatestTs"> <div class="tb-form-row simpleMode flex-1">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}"> <mat-slide-toggle class="mat-slide" formControlName="useLatestTs">
<div translate tbTruncateWithTooltip>calculated-fields.use-latest-timestamp</div> <div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}">
</div> <div translate tbTruncateWithTooltip>calculated-fields.use-latest-timestamp</div>
</mat-slide-toggle> </div>
</div> </mat-slide-toggle>
</div>
}
</tb-calculate-field-output> </tb-calculate-field-output>
</div> </div>

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

@ -23,6 +23,7 @@
[tenantId]="tenantId" [tenantId]="tenantId"
[ownerId]="ownerId" [ownerId]="ownerId"
[entityName]="entityName" [entityName]="entityName"
[watchKeyChange]="true"
[isScript]="isScript" /> [isScript]="isScript" />
</div> </div>
<div class="tb-form-panel no-gap"> <div class="tb-form-panel no-gap">

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

@ -368,6 +368,34 @@
<mat-hint></mat-hint> <mat-hint></mat-hint>
</mat-form-field> </mat-form-field>
</div> </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.intermediate-aggregation-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="intermediateAggregationIntervalInSecForCF"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('intermediateAggregationIntervalInSecForCF').hasError('required')">
{{ 'tenant-profile.intermediate-aggregation-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('intermediateAggregationIntervalInSecForCF').hasError('min')">
{{ 'tenant-profile.intermediate-aggregation-interval-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.reevaluation-check-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="cfReevaluationCheckInterval"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('cfReevaluationCheckInterval').hasError('required')">
{{ 'tenant-profile.reevaluation-check-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('cfReevaluationCheckInterval').hasError('min')">
{{ 'tenant-profile.reevaluation-check-interval-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4"> <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-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.relation-search-entity-limit</mat-label> <mat-label translate>tenant-profile.relation-search-entity-limit</mat-label>
@ -526,6 +554,22 @@
<mat-hint></mat-hint> <mat-hint></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="fields-element 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.alarms-reevaluation-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="alarmsReevaluationInterval"
type="number">
<mat-error *ngIf="tenantProfileConfigurationForm.get('alarmsReevaluationInterval').hasError('required')">
{{ 'tenant-profile.alarms-reevaluation-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="tenantProfileConfigurationForm.get('alarmsReevaluationInterval').hasError('min')">
{{ 'tenant-profile.alarms-reevaluation-interval-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<div class="flex-1"></div>
</div>
</fieldset> </fieldset>
<fieldset class="fields-group"> <fieldset class="fields-group">

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

@ -120,6 +120,9 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA
minAllowedAggregationIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]], minAllowedAggregationIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]],
maxRelatedEntitiesToReturnPerCfArgument: [1, [Validators.required, Validators.min(1)]], maxRelatedEntitiesToReturnPerCfArgument: [1, [Validators.required, Validators.min(1)]],
minAllowedScheduledUpdateIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]], minAllowedScheduledUpdateIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]],
intermediateAggregationIntervalInSecForCF: [0, [Validators.required, Validators.min(1)]],
cfReevaluationCheckInterval: [0, [Validators.required, Validators.min(1)]],
alarmsReevaluationInterval: [0, [Validators.required, Validators.min(1)]],
maxDataPointsPerRollingArg: [0, [Validators.required, Validators.min(0)]], maxDataPointsPerRollingArg: [0, [Validators.required, Validators.min(0)]],
maxStateSizeInKBytes: [0, [Validators.required, Validators.min(0)]], maxStateSizeInKBytes: [0, [Validators.required, Validators.min(0)]],
calculatedFieldDebugEventsRateLimit: [''], calculatedFieldDebugEventsRateLimit: [''],

16
ui-ngx/src/app/modules/home/components/relation/relation-table.component.html

@ -104,8 +104,20 @@
</ng-container> </ng-container>
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<mat-header-cell *matHeaderCellDef mat-sort-header style="width: 33%"> {{ 'relation.type' | translate }} </mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header style="width: 33%"> {{ 'relation.type' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let relation"> <mat-cell *matCellDef="let relation" class="type">
{{ relation.type }} <div class="flex flex-row" style="align-items: center">
<span class="ellipsis">{{ relation.type }}</span>
<tb-copy-button
class="type-copy"
[disabled]="isLoading$ | async"
[copyText]="relation.type"
tooltipText="{{ 'relation.copy-type' | translate }}"
tooltipPosition="above"
icon="content_copy"
[style]="{padding: '4px', 'font-size': '16px', color: 'rgba(0,0,0,.87)'}"
>
</tb-copy-button>
</div>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container *ngIf="direction === directions.FROM" matColumnDef="toEntityTypeName"> <ng-container *ngIf="direction === directions.FROM" matColumnDef="toEntityTypeName">

11
ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss

@ -59,6 +59,17 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.type-copy {
visibility: hidden;
transition: visibility 0.1s;
}
.type:hover {
.type-copy {
visibility: visible;
}
}
} }
} }

11
ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html

@ -15,8 +15,11 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-two-factor-auth-login-content mat-app-background tb-dark flex flex-row items-center justify-center" <div class="tb-two-factor-auth-login-content mat-app-background tb-dark flex flex-col items-center justify-center"
style="width: 100%;"> style="width: 100%;">
@if (isLoading$ | async) {
<mat-progress-bar color="warn" class="progress-bar" mode="indeterminate"></mat-progress-bar>
}
@switch (state()) { @switch (state()) {
@case (ForceTwoFAState.SETUP) { @case (ForceTwoFAState.SETUP) {
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial"> <mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
@ -59,7 +62,7 @@
</mat-card-title> </mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="flex flex-col items-center justify-start"> <div tb-toast class="flex flex-col items-center justify-start">
<p class="mat-body qr-code-description mb-4" translate>login.scan-qr-code</p> <p class="mat-body qr-code-description mb-4" translate>login.scan-qr-code</p>
<canvas class="flex-1" #canvas [style.display]="totpAuthURL ? 'block' : 'none'"></canvas> <canvas class="flex-1" #canvas [style.display]="totpAuthURL ? 'block' : 'none'"></canvas>
<p class="mat-body qr-code-description" translate>login.enter-key-manually</p> <p class="mat-body qr-code-description" translate>login.enter-key-manually</p>
@ -109,7 +112,7 @@
</mat-card-title> </mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="flex flex-col items-center justify-start"> <div tb-toast class="flex flex-col items-center justify-start">
<form [formGroup]="smsConfigForm" class="mb-12"> <form [formGroup]="smsConfigForm" class="mb-12">
<p class="mat-body step-description input" translate>login.sms-description</p> <p class="mat-body step-description input" translate>login.sms-description</p>
<div class="flex flex-row items-center justify-between gap-3.75"> <div class="flex flex-row items-center justify-between gap-3.75">
@ -156,7 +159,7 @@
</mat-card-title> </mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="flex flex-col items-center justify-start"> <div tb-toast class="flex flex-col items-center justify-start">
<form [formGroup]="emailConfigForm" class="mb-8 w-full"> <form [formGroup]="emailConfigForm" class="mb-8 w-full">
<p class="mat-body step-description input" translate>login.email-description</p> <p class="mat-body step-description input" translate>login.email-description</p>
<mat-form-field class="mat-block input-container flex-1"> <mat-form-field class="mat-block input-container flex-1">

6
ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss

@ -24,6 +24,12 @@
.tb-two-factor-auth-login-content { .tb-two-factor-auth-login-content {
background-color: #eee; background-color: #eee;
.progress-bar {
z-index: 10;
margin-bottom: -4px;
width: 450px;
}
.tb-two-factor-auth-login-card { .tb-two-factor-auth-login-card {
max-height: 100vh; max-height: 100vh;
overflow: auto; overflow: auto;

8
ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts

@ -39,6 +39,7 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import printTemplate from '@home/pages/security/authentication-dialog/backup-code-print-template.raw'; import printTemplate from '@home/pages/security/authentication-dialog/backup-code-print-template.raw';
import { ImportExportService } from '@shared/import-export/import-export.service'; import { ImportExportService } from '@shared/import-export/import-export.service';
import { mergeMap, tap } from 'rxjs/operators'; import { mergeMap, tap } from 'rxjs/operators';
import { ActionNotificationShow } from "@core/notification/notification.actions";
enum ForceTwoFAState { enum ForceTwoFAState {
SETUP = 'setup', SETUP = 'setup',
@ -261,6 +262,13 @@ export class ForceTwoFactorAuthLoginComponent extends PageComponent implements O
this.configForm.get('verificationCode').setErrors({incorrectCode: true}); this.configForm.get('verificationCode').setErrors({incorrectCode: true});
} else if (error.status === 429) { } else if (error.status === 429) {
this.configForm.get('verificationCode').setErrors({tooManyRequest: true}); this.configForm.get('verificationCode').setErrors({tooManyRequest: true});
} else {
this.store.dispatch(new ActionNotificationShow({
message: error.error.message,
type: 'error',
verticalPosition: 'top',
horizontalPosition: 'left'
}));
} }
} }
}) })

4
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts

@ -475,4 +475,8 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit
get showEntityLink(): boolean { get showEntityLink(): boolean {
return this.selectEntityFormGroup.get('entity').value && this.disabled && this.entityURL !== ''; return this.selectEntityFormGroup.get('entity').value && this.disabled && this.entityURL !== '';
} }
markAsTouched(): void {
this.selectEntityFormGroup.get('entity').markAsTouched();
}
} }

4
ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts

@ -173,4 +173,8 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit,
return ''; return '';
} }
} }
markAsTouched(): void {
this.entityTypeFormGroup.get('entityType').markAsTouched();
}
} }

8
ui-ngx/src/app/shared/components/string-autocomplete.component.html

@ -31,21 +31,21 @@
</button> </button>
<mat-icon *ngIf="selectionFormControl.errors && selectionFormControl.touched && !showInlineError" <mat-icon *ngIf="selectionFormControl.errors && selectionFormControl.touched && !showInlineError"
matIconSuffix matIconSuffix
[matTooltip]="errorText" [matTooltip]="getErrorMessage"
matTooltipPosition="above" matTooltipPosition="above"
[matTooltipClass]="tooltipClass" [matTooltipClass]="tooltipClass"
class="material-icons tb-suffix-show-always tb-error"> class="material-icons tb-suffix-show-always tb-error">
warning warning
</mat-icon> </mat-icon>
<mat-error *ngIf="selectionFormControl.errors && showInlineError"> <mat-error *ngIf="selectionFormControl.errors && showInlineError">
{{errorText}} {{ getErrorMessage }}
</mat-error> </mat-error>
<mat-autocomplete <mat-autocomplete
#optionsAutocomplete="matAutocomplete" #optionsAutocomplete="matAutocomplete"
class="tb-autocomplete tb-options-input-autocomplete" class="tb-autocomplete"
[panelWidth]="panelWidth"> [panelWidth]="panelWidth">
<mat-option *ngFor="let option of filteredOptions$ | async" [value]="option"> <mat-option *ngFor="let option of filteredOptions$ | async" [value]="option">
<span class="tb-option-name flex-1" [innerHTML]="option | highlight:searchText:true:'ig'"></span> <span [innerHTML]="option | highlight:searchText:true:'ig'"></span>
</mat-option> </mat-option>
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>

20
ui-ngx/src/app/shared/components/string-autocomplete.component.scss

@ -25,25 +25,5 @@
margin-right: 8px; margin-right: 8px;
} }
} }
.tb-autocomplete.tb-option-input-autocomplete {
.mat-mdc-option {
border-bottom: none;
.mdc-list-item__primary-text {
flex: 1;
display: flex;
flex-direction: row;
gap: 8px;
.tb-option {
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
}
}
}
}
} }
} }

50
ui-ngx/src/app/shared/components/string-autocomplete.component.ts

@ -14,27 +14,25 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
Component,
Input,
forwardRef,
OnInit,
ViewChild,
ElementRef
} from '@angular/core';
import { import {
ControlValueAccessor, ControlValueAccessor,
NG_VALUE_ACCESSOR, FormBuilder,
FormControl, FormControl,
Validators, NG_VALUE_ACCESSOR,
FormBuilder ValidatorFn,
Validators
} from '@angular/forms'; } from '@angular/forms';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { tap, map, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
export interface ErrorMessageConfig {
[errorKey: string]: string;
}
@Component({ @Component({
selector: 'tb-string-autocomplete', selector: 'tb-string-autocomplete',
templateUrl: './string-autocomplete.component.html', templateUrl: './string-autocomplete.component.html',
@ -85,6 +83,12 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit
@Input() @Input()
errorText: string; errorText: string;
@Input()
controlValidators: ValidatorFn[] = [];
@Input()
errorMessages: ErrorMessageConfig;
@Input() @Input()
@coerceBoolean() @coerceBoolean()
showInlineError = false; showInlineError = false;
@ -107,7 +111,13 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit
ngOnInit() { ngOnInit() {
const validators = [Validators.pattern(/.*\S.*/)]; const validators = [Validators.pattern(/.*\S.*/)];
if (this.required) { if (this.controlValidators?.length) {
validators.push(...this.controlValidators);
const parentHasRequired = this.controlValidators.some(v => v === Validators.required);
if (this.required && !parentHasRequired) {
validators.push(Validators.required);
}
} else if (this.required) {
validators.push(Validators.required); validators.push(Validators.required);
} }
this.selectionFormControl = this.fb.control('', validators); this.selectionFormControl = this.fb.control('', validators);
@ -184,4 +194,18 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit
this.nameInput.nativeElement.focus(); this.nameInput.nativeElement.focus();
}, 0); }, 0);
} }
get getErrorMessage(): string {
if (!this.selectionFormControl.errors) {
return '';
}
if (this.errorMessages) {
for (const errorKey in this.selectionFormControl.errors) {
if (this.errorMessages[errorKey]) {
return this.errorMessages[errorKey];
}
}
}
return this.errorText;
}
} }

4
ui-ngx/src/app/shared/import-export/import-export.service.ts

@ -183,8 +183,8 @@ export class ImportExportService {
}); });
} }
public openCalculatedFieldImportDialog(): Observable<CalculatedField> { public openCalculatedFieldImportDialog(importTitle = 'calculated-fields.import', importFileLabel = 'calculated-fields.file'): Observable<CalculatedField> {
return this.openImportDialog('calculated-fields.import', 'calculated-fields.file').pipe( return this.openImportDialog(importTitle, importFileLabel).pipe(
catchError(() => of(null)), catchError(() => of(null)),
); );
} }

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

@ -18,17 +18,10 @@
import { CustomTimeSchedulerItem } from "@shared/models/device.models"; import { CustomTimeSchedulerItem } from "@shared/models/device.models";
import { DashboardId } from "@shared/models/id/dashboard-id"; import { DashboardId } from "@shared/models/id/dashboard-id";
import { TimeUnit } from "@shared/models/time/time.models"; import { TimeUnit } from "@shared/models/time/time.models";
import { import { ComplexOperation, EntityKeyValueType, FilterPredicateType } from "@shared/models/query/query.models";
BooleanOperation,
ComplexOperation,
EntityKeyValueType,
FilterPredicateType,
NumericOperation,
StringOperation
} from "@shared/models/query/query.models";
import { EntityType } from "@shared/models/entity-type.models"; import { EntityType } from "@shared/models/entity-type.models";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { CalculatedField } from "@shared/models/calculated-field.models"; import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models";
export enum AlarmRuleScheduleType { export enum AlarmRuleScheduleType {
ANY_TIME = 'ANY_TIME', ANY_TIME = 'ANY_TIME',
@ -250,3 +243,62 @@ export const alarmRuleDefaultScript =
'return temperature > 20;' 'return temperature > 20;'
export type AlarmRuleTestScriptFn = (calculatedField: CalculatedField, expression: string, argumentsObj?: Record<string, unknown>, closeAllOnSave?: boolean) => Observable<string>; export type AlarmRuleTestScriptFn = (calculatedField: CalculatedField, expression: string, argumentsObj?: Record<string, unknown>, closeAllOnSave?: boolean) => Observable<string>;
export function checkPredicates(predicates: any[], validSet: Set<string>): boolean {
for (const predicate of predicates) {
if (!predicate) continue;
if (predicate?.value?.dynamicValueArgument) {
if (!validSet.has(predicate.value.dynamicValueArgument)) {
return false;
}
}
if (predicate.type === 'COMPLEX' && Array.isArray(predicate.predicates)) {
if (!checkPredicates(predicate.predicates, validSet)) {
return false;
}
}
}
return true;
}
export function areFilterAndPredicateArgumentsValid(obj: any, args: Record<string, CalculatedFieldArgument>): boolean {
const validSet = new Set(Object.keys(args));
const filter = obj || [];
if (filter.argument && !validSet.has(filter.argument)) {
return false;
}
if (Array.isArray(filter.predicates)) {
if (!checkPredicates(filter.predicates, validSet)) {
return false;
}
}
return true;
}
export function areFiltersAndPredicateArgumentsValid(obj: any, args: Record<string, CalculatedFieldArgument>): boolean {
const validSet = new Set(Object.keys(args));
const filters = obj || [];
for (const filter of filters) {
if (filter.argument && !validSet.has(filter.argument)) {
return false;
}
}
for (const filter of filters) {
if (Array.isArray(filter.predicates)) {
if (!checkPredicates(filter.predicates, validSet)) {
return false;
}
}
}
return true;
}
export function isPredicateArgumentsValid(predicates: any, args: Record<string, CalculatedFieldArgument>): boolean {
const validSet = new Set(Object.keys(args));
if (Array.isArray(predicates)) {
if (!checkPredicates(predicates, validSet)) {
return false;
}
}
return true;
}

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

@ -168,6 +168,7 @@ export interface CalculatedFieldEntityAggregationConfiguration {
metrics: Record<string, CalculatedFieldAggMetric>; metrics: Record<string, CalculatedFieldAggMetric>;
interval: AggInterval; interval: AggInterval;
watermark?: WatermarkConfig; watermark?: WatermarkConfig;
produceIntermediateResult?: boolean;
output: CalculatedFieldOutput & { decimalsByDefault?: number; }; output: CalculatedFieldOutput & { decimalsByDefault?: number; };
} }

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

@ -111,6 +111,10 @@ export interface DefaultTenantProfileConfiguration {
minAllowedAggregationIntervalInSecForCF: number; minAllowedAggregationIntervalInSecForCF: number;
maxRelatedEntitiesToReturnPerCfArgument: number; maxRelatedEntitiesToReturnPerCfArgument: number;
minAllowedScheduledUpdateIntervalInSecForCF: number; minAllowedScheduledUpdateIntervalInSecForCF: number;
intermediateAggregationIntervalInSecForCF: number;
cfReevaluationCheckInterval: number;
alarmsReevaluationInterval: number;
maxDataPointsPerRollingArg: number; maxDataPointsPerRollingArg: number;
maxStateSizeInKBytes: number; maxStateSizeInKBytes: number;
maxSingleValueArgumentSizeInKBytes: number; maxSingleValueArgumentSizeInKBytes: number;
@ -176,10 +180,13 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan
maxArgumentsPerCF: 10, maxArgumentsPerCF: 10,
maxDataPointsPerRollingArg: 1000, maxDataPointsPerRollingArg: 1000,
maxRelationLevelPerCfArgument: 10, maxRelationLevelPerCfArgument: 10,
minAllowedDeduplicationIntervalInSecForCF: 60, minAllowedDeduplicationIntervalInSecForCF: 10,
minAllowedAggregationIntervalInSecForCF: 60, minAllowedAggregationIntervalInSecForCF: 60,
maxRelatedEntitiesToReturnPerCfArgument: 100, maxRelatedEntitiesToReturnPerCfArgument: 100,
minAllowedScheduledUpdateIntervalInSecForCF: 0, minAllowedScheduledUpdateIntervalInSecForCF: 0,
intermediateAggregationIntervalInSecForCF: 300,
cfReevaluationCheckInterval: 60,
alarmsReevaluationInterval: 60,
maxStateSizeInKBytes: 32, maxStateSizeInKBytes: 32,
maxSingleValueArgumentSizeInKBytes: 2, maxSingleValueArgumentSizeInKBytes: 2,
calculatedFieldDebugEventsRateLimit: '' calculatedFieldDebugEventsRateLimit: ''

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

@ -49,7 +49,7 @@ The argument value for a specific time schedule must be a JSON object in the fol
```javascript ```javascript
{ {
"type": "CUSTOM" "type": "CUSTOM",
"timezone": "Europe/Kiev", "timezone": "Europe/Kiev",
"items": [ "items": [
{ {

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

@ -1122,7 +1122,7 @@
"add-argument": "Add argument", "add-argument": "Add argument",
"test-script-function": "Test script function", "test-script-function": "Test script function",
"test-expression-function": "Test expression function", "test-expression-function": "Test expression function",
"no-arguments": "No arguments configured", "no-arguments": "At least one argument is required.",
"argument-settings": "Argument settings", "argument-settings": "Argument settings",
"argument-current": "Current entity", "argument-current": "Current entity",
"argument-current-tenant": "Current tenant", "argument-current-tenant": "Current tenant",
@ -1176,7 +1176,7 @@
"target-zone": "Target zone", "target-zone": "Target zone",
"perimeter-key": "Perimeter key", "perimeter-key": "Perimeter key",
"report-strategy": "Report strategy", "report-strategy": "Report strategy",
"no-zone-configured": "No zone group configured", "no-zone-configured": "At least one zone is required.",
"no-zone-configured-required": "At least one zone group must be configured.", "no-zone-configured-required": "At least one zone group must be configured.",
"add-zone-group": "Add zone group", "add-zone-group": "Add zone group",
"report-transition-event-only": "Transition events only", "report-transition-event-only": "Transition events only",
@ -1245,7 +1245,7 @@
"key": "Key", "key": "Key",
"function": "Function" "function": "Function"
}, },
"no-metrics-configured": "No metrics configured", "no-metrics-configured": "At least one metric is required.",
"add-metric": "Add metric", "add-metric": "Add metric",
"max-metrics": "Maximum number of metrics reached.", "max-metrics": "Maximum number of metrics reached.",
"metric-settings": "Metric settings", "metric-settings": "Metric settings",
@ -1268,7 +1268,7 @@
"ttl-min": "Only 0 minimum TTL is allowed", "ttl-min": "Only 0 minimum TTL is allowed",
"processing-parameters": "Processing parameters", "processing-parameters": "Processing parameters",
"hint": { "hint": {
"strategy": "Controls whether the result is processed immediately or sent to a rule chain for additional processing", "strategy": "Controls whether the result is processed immediately or sent to a rule chain for additional processing.",
"processing-options": "Processing options", "processing-options": "Processing options",
"update-attribute-only-on-value-change": "Updates attribute on every incoming message, regardless of whether the value has changed. This increases API usage and reduces performance.", "update-attribute-only-on-value-change": "Updates attribute on every incoming message, regardless of whether the value has changed. This increases API usage and reduces performance.",
"update-attribute-only-on-value-change-enabled": "Updates attribute only when the value changes. If the value is unchanged, timestamps are not updated and notifications are not sent.", "update-attribute-only-on-value-change-enabled": "Updates attribute only when the value changes. If the value is unchanged, timestamps are not updated and notifications are not sent.",
@ -1316,7 +1316,9 @@
"duration": "Duration", "duration": "Duration",
"duration-required": "Duration is required", "duration-required": "Duration is required",
"duration-min": "Duration should be at least 1 minute", "duration-min": "Duration should be at least 1 minute",
"duration-hint": "How long to wait for delayed data after the interval ends" "duration-hint": "How long to wait for delayed data after the interval ends",
"produce-intermediate-result": "Produce intermediate result",
"produce-intermediate-result-hint": "Calculates metrics during the current interval to produce an intermediate result. Updates occur no more often than once every {{ time }}."
}, },
"hint": { "hint": {
"arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.",
@ -1380,7 +1382,7 @@
"alarm-rules-old": "Old", "alarm-rules-old": "Old",
"alarm-rules-actual": "Actual", "alarm-rules-actual": "Actual",
"severities": "Severities", "severities": "Severities",
"cleared": "Clearing condition", "cleared": "Clear condition",
"delete-title": "Are you sure you want to delete the alarm rule '{{title}}'?", "delete-title": "Are you sure you want to delete the alarm rule '{{title}}'?",
"delete-text": "Be careful, after the confirmation the alarm rule and all related data will become unrecoverable.", "delete-text": "Be careful, after the confirmation the alarm rule and all related data will become unrecoverable.",
"delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 alarm rule} other {# alarm rules} }?", "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 alarm rule} other {# alarm rules} }?",
@ -1392,6 +1394,7 @@
"list": "{ count, plural, =1 {One alarm rule} other {List of # alarm rules} }", "list": "{ count, plural, =1 {One alarm rule} other {List of # alarm rules} }",
"selected-fields": "{ count, plural, =1 {1 alarm rule} other {# alarm rules} } selected", "selected-fields": "{ count, plural, =1 {1 alarm rule} other {# alarm rules} } selected",
"import": "Import alarm rule", "import": "Import alarm rule",
"file": "Alarm rule file",
"export": "Export alarm rule", "export": "Export alarm rule",
"export-failed-error": "Unable to export alarm rule: {{error}}", "export-failed-error": "Unable to export alarm rule: {{error}}",
"entity-type": "Entity type", "entity-type": "Entity type",
@ -1400,6 +1403,7 @@
"target-entity": "Target entity", "target-entity": "Target entity",
"target-entities": "Target entities", "target-entities": "Target entities",
"alarm-type": "Alarm type", "alarm-type": "Alarm type",
"alarm-type-hint": "Unique identifier (e.g., HighTemperatureAlarm) across the scope of the alarm originator (Device, Asset, etc.) to prevent conflicts.",
"alarm-type-required": "Alarm type is required.", "alarm-type-required": "Alarm type is required.",
"alarm-type-pattern": "Alarm type is invalid.", "alarm-type-pattern": "Alarm type is invalid.",
"alarm-type-max-length": "Alarm type should be less than 256 characters.", "alarm-type-max-length": "Alarm type should be less than 256 characters.",
@ -1435,8 +1439,7 @@
"add-filter": "Add argument filter", "add-filter": "Add argument filter",
"edit-filter": "Argument filter", "edit-filter": "Argument filter",
"remove-filter": "Remove argument filter", "remove-filter": "Remove argument filter",
"no-filter": "No argument filters configured", "no-filter": "At least one filter is required.",
"filter-required": "At least one filter must be configured.",
"conditions": { "conditions": {
"simple": "Simple", "simple": "Simple",
"duration": "Duration", "duration": "Duration",
@ -1495,9 +1498,9 @@
"condition-type": "Condition type", "condition-type": "Condition type",
"condition-type-hint": "\"Duration\" and \"Repeating\" options are not available when the \"Missing for\" operation is used in the filter.", "condition-type-hint": "\"Duration\" and \"Repeating\" options are not available when the \"Missing for\" operation is used in the filter.",
"select-alarm-severity": "Select alarm severity", "select-alarm-severity": "Select alarm severity",
"add-create-alarm-rule-prompt": "At least one creation condition should be configured", "add-create-alarm-rule-prompt": "At least one trigger condition is required.",
"add-create-alarm-rule": "Add creation condition", "add-create-alarm-rule": "Add trigger condition",
"add-clear-alarm-rule": "Add clearing condition", "add-clear-alarm-rule": "Add clear condition",
"condition-duration": "Condition duration", "condition-duration": "Condition duration",
"condition-duration-value": "Duration value", "condition-duration-value": "Duration value",
"condition-duration-time-unit": "Time unit", "condition-duration-time-unit": "Time unit",
@ -1510,9 +1513,9 @@
"condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.",
"condition-repeating-value-pattern": "Count of events should be integers.", "condition-repeating-value-pattern": "Count of events should be integers.",
"condition-repeating-value-required": "Count of events is required.", "condition-repeating-value-required": "Count of events is required.",
"create-conditions": "Creation conditions", "create-conditions": "Alarm Trigger Conditions",
"clear-condition": "Clearing condition", "clear-condition": "Alarm Clear Condition",
"no-clear-alarm-rule": "Clearing condition not configured", "no-clear-alarm-rule": "No clear condition configured.",
"advanced-settings": "Advanced settings", "advanced-settings": "Advanced settings",
"propagate-alarm": "Propagate alarm to related entities", "propagate-alarm": "Propagate alarm to related entities",
"alarm-rule-relation-types-list": "Relation types", "alarm-rule-relation-types-list": "Relation types",
@ -4990,7 +4993,8 @@
"additional-info": "Additional info (JSON)", "additional-info": "Additional info (JSON)",
"invalid-additional-info": "Unable to parse additional info json.", "invalid-additional-info": "Unable to parse additional info json.",
"no-relations-text": "No relations found", "no-relations-text": "No relations found",
"not": "Not" "not": "Not",
"copy-type": "Copy type"
}, },
"resource": { "resource": {
"add": "Add resource", "add": "Add resource",
@ -6282,6 +6286,15 @@
"min-allowed-deduplication-interval": "Min allowed deduplication interval (seconds)", "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-range": "Min allowed deduplication interval value can't be negative",
"min-allowed-deduplication-interval-required": "Min allowed deduplication interval is required", "min-allowed-deduplication-interval-required": "Min allowed deduplication interval is required",
"intermediate-aggregation-interval": "Intermediate aggregation interval (seconds)",
"intermediate-aggregation-interval-range": "Intermediate aggregation interval value can't be less than '1'",
"intermediate-aggregation-interval-required": "Intermediate aggregation interval is required",
"reevaluation-check-interval": "Reevaluation check interval (seconds)",
"reevaluation-check-interval-range": "Reevaluation check interval value can't be less than '1'",
"reevaluation-check-interval-required": "Reevaluation check interval is required",
"alarms-reevaluation-interval": "Alarms reevaluation interval (seconds)",
"alarms-reevaluation-interval-range": "Alarms reevaluation interval value can't be less than '1'",
"alarms-reevaluation-interval-required": "Alarms reevaluation interval is required",
"min-allowed-aggregation-interval": "Min allowed aggregation interval (seconds)", "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-range": "Min allowed aggregation interval value can't be negative",
"min-allowed-aggregation-interval-required": "Min allowed aggregation interval is required", "min-allowed-aggregation-interval-required": "Min allowed aggregation interval is required",

Loading…
Cancel
Save