Browse Source

Merge branch 'rc' into bug/latest-values-map-with-key-value-agg

pull/14685/head
Ekaterina Chantsova 9 months ago
committed by GitHub
parent
commit
9f071f1238
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 51
      .github/release.yml
  2. 2
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  3. 15
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  4. 2
      application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java
  5. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  6. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java
  8. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java
  9. 5
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java
  10. 68
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java
  11. 9
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java
  12. 9
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  13. 8
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
  14. 3
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
  15. 21
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java
  16. 11
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java
  17. 44
      application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java
  18. 3
      application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
  19. 2
      application/src/main/resources/thingsboard.yml
  20. 131
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  21. 53
      application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
  22. 2
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  23. 2
      application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
  24. 64
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  25. 5
      application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java
  26. 2
      application/src/test/java/org/thingsboard/server/edge/EdgeStatsIntegrationTest.java
  27. 4
      application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
  28. 25
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java
  29. 57
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java
  30. 113
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java
  31. 15
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java
  32. 23
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java
  33. 17
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java
  34. 25
      application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java
  35. 40
      application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java
  36. 21
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java
  37. 14
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java
  38. 69
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java
  39. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java
  40. 2
      dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java
  41. 3
      dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java
  42. 3
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  43. 5
      dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java
  44. 4
      dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java
  45. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
  46. 212
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java
  47. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/base/AbstractDriverBaseTest.java
  48. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/CreateDeviceProfileImportTest.java
  49. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/CreateDeviceProfileTest.java
  50. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/DeviceProfileEditMenuTest.java
  51. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/SearchDeviceProfileTest.java
  52. 14
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/SortByNameTest.java
  53. 6
      msa/js-executor/yarn.lock
  54. 88
      msa/pom.xml
  55. 15
      msa/tb-node/docker/Dockerfile
  56. 6
      msa/web-ui/yarn.lock
  57. 60
      ui-ngx/patches/@mat-datetimepicker+core+14.0.0.patch
  58. 112
      ui-ngx/src/app/core/services/calculated-field-form.service.ts
  59. 4
      ui-ngx/src/app/core/services/utils.service.ts
  60. 2
      ui-ngx/src/app/modules/common/modules-map.ts
  61. 38
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html
  62. 74
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts
  63. 5
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts
  64. 84
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  65. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts
  66. 160
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html
  67. 199
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts
  68. 12
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts
  69. 16
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html
  70. 45
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts
  71. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html
  72. 6
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts
  73. 19
      ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts
  74. 146
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html
  75. 52
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss
  76. 167
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts
  77. 5
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  78. 114
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  79. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts
  80. 16
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  81. 12
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  82. 43
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html
  83. 11
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts
  84. 56
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  85. 93
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  86. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
  87. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
  88. 25
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html
  89. 8
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts
  90. 39
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html
  91. 7
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts
  92. 18
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts
  93. 31
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html
  94. 19
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts
  95. 29
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html
  96. 9
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts
  97. 6
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  98. 6
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  99. 6
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts
  100. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html

51
.github/release.yml

@ -23,27 +23,19 @@ changelog:
labels:
- 'Major Core'
- 'Major Rule Engine'
exclude:
labels:
- 'Bug'
- title: 'Major UI'
labels:
- 'Major UI'
exclude:
labels:
- 'Bug'
- title: 'Major Transport'
labels:
- 'Major Transport'
exclude:
labels:
- 'Bug'
- title: 'Major Edge'
labels:
- 'Major Edge'
exclude:
labels:
- 'Bug'
- title: 'Core & Rule Engine'
labels:
- 'Core'
@ -51,38 +43,63 @@ changelog:
exclude:
labels:
- 'Bug'
- title: 'UI'
labels:
- 'UI'
exclude:
labels:
- 'Bug'
- title: 'Transport'
labels:
- 'Transport'
exclude:
labels:
- 'Bug'
- title: 'Edge'
labels:
- 'Edge'
exclude:
labels:
- 'Bug'
- title: 'Bug: Core & Rule Engine'
labels:
- 'Core'
- 'Rule Engine'
- 'Bug'
exclude:
labels:
- 'UI'
- 'Transport'
- 'Edge'
- title: 'Bug: UI'
labels:
- 'UI'
- 'Bug'
exclude:
labels:
- 'Core'
- 'Rule Engine'
- 'Transport'
- 'Edge'
- title: 'Bug: Transport'
labels:
- 'Transport'
- 'Bug'
exclude:
labels:
- 'Core'
- 'Rule Engine'
- 'UI'
- 'Edge'
- title: 'Bug: Edge'
labels:
- 'Edge'
- 'Bug'
exclude:
labels:
- 'Core'
- 'Rule Engine'
- 'UI'
- 'Transport'

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

@ -846,7 +846,7 @@ public class ActorSystemContext {
if (tbMsgType != null) {
eventBuilder.msgType(tbMsgType);
}
if (arguments != null) {
if (arguments != null && !arguments.isEmpty()) {
eventBuilder.arguments(JacksonUtil.toString(arguments));
}
if (result != null) {

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

@ -572,24 +572,24 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, List<TsKvProto> data) {
return mapToArguments(entityId, ctx.getMainEntityArguments(), Collections.emptyMap(), data);
return mapToArguments(entityId, ctx, ctx.getMainEntityArguments(), Collections.emptyMap(), data);
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, List<TsKvProto> data) {
return mapToArguments(entityId, ctx.getLinkedAndDynamicArgs(entityId), ctx.getRelatedEntityArguments(), data);
return mapToArguments(entityId, ctx, ctx.getLinkedAndDynamicArgs(entityId), ctx.getRelatedEntityArguments(), data);
}
private Map<String, ArgumentEntry> mapToArguments(EntityId originator, Map<ReferencedEntityKey, Set<String>> args, Map<ReferencedEntityKey, Set<String>> relatedEntityArgs, List<TsKvProto> data) {
private Map<String, ArgumentEntry> mapToArguments(EntityId originator, CalculatedFieldCtx ctx, Map<ReferencedEntityKey, Set<String>> args, Map<ReferencedEntityKey, Set<String>> relatedEntityArgs, List<TsKvProto> data) {
Map<String, ArgumentEntry> arguments = new HashMap<>();
if (!relatedEntityArgs.isEmpty() || !args.isEmpty()) {
for (TsKvProto item : data) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null);
SingleValueArgumentEntry relatedArgIncoming = new SingleValueArgumentEntry(originator, item);
mapLatest(relatedArgIncoming, relatedEntityArgs.get(key), arguments);
mapLatest(ctx, relatedArgIncoming, relatedEntityArgs.get(key), arguments);
SingleValueArgumentEntry incoming = new SingleValueArgumentEntry(item);
mapLatest(incoming, args.get(key), arguments);
mapLatest(ctx, incoming, args.get(key), arguments);
key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null);
mapRolling(item, args.get(key), arguments);
@ -598,7 +598,8 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
return arguments;
}
private void mapLatest(SingleValueArgumentEntry incoming,
private void mapLatest(CalculatedFieldCtx ctx,
SingleValueArgumentEntry incoming,
Set<String> argNames,
Map<String, ArgumentEntry> arguments) {
if (argNames != null) {
@ -606,7 +607,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (existing == null) {
return incoming;
}
existing.updateEntry(incoming);
existing.updateEntry(incoming, ctx);
return existing;
}));
}

2
application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java

@ -75,7 +75,7 @@ public class EntityQueryController extends BaseController {
}
@ApiOperation(value = "Find Entity Data by Query", notes = ENTITY_DATA_QUERY_DESCRIPTION)
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PostMapping("/entitiesQuery/find")
public PageData<EntityData> findEntityDataByQuery(
@Parameter(description = "A JSON value representing the entity data query. See API call notes above for more details.")

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

@ -52,7 +52,7 @@ public interface ArgumentEntry {
Object getValue();
boolean updateEntry(ArgumentEntry entry);
boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx);
boolean isEmpty();

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

@ -86,13 +86,13 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
validateNewEntry(key, newEntry);
if (existingEntry instanceof RelatedEntitiesArgumentEntry ||
existingEntry instanceof EntityAggregationArgumentEntry) {
updateEntry(existingEntry, newEntry);
updateEntry(existingEntry, newEntry, ctx);
} else {
arguments.put(key, newEntry);
}
entryUpdated = true;
} else {
entryUpdated = updateEntry(existingEntry, newEntry);
entryUpdated = updateEntry(existingEntry, newEntry, ctx);
}
if (entryUpdated) {
@ -111,8 +111,8 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
return updatedArguments;
}
protected boolean updateEntry(ArgumentEntry existingEntry, ArgumentEntry newEntry) {
return existingEntry.updateEntry(newEntry);
protected boolean updateEntry(ArgumentEntry existingEntry, ArgumentEntry newEntry, CalculatedFieldCtx ctx) {
return existingEntry.updateEntry(newEntry, ctx);
}
@Override
@ -201,7 +201,9 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
@Override
public JsonNode getArgumentsJson() {
return JacksonUtil.valueToTree(arguments.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue())));
.filter(entry -> !entry.getValue().isEmpty())
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue()))
);
}
}

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

@ -159,7 +159,7 @@ public class SingleValueArgumentEntry implements ArgumentEntry {
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof SingleValueArgumentEntry singleValueEntry) {
if (singleValueEntry.getTs() < this.ts) {
return false;

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

@ -100,7 +100,7 @@ public class TsRollingArgumentEntry implements ArgumentEntry, HasLatestTs {
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof TsRollingArgumentEntry tsRollingEntry) {
updateTsRollingEntry(tsRollingEntry);
} else if (entry instanceof SingleValueArgumentEntry singleValueEntry) {

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

@ -23,6 +23,7 @@ import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
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.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.HasLatestTs;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
@ -63,7 +64,7 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {
entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs);
return true;
@ -74,7 +75,7 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs
}
ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId());
if (argumentEntry != null) {
argumentEntry.updateEntry(singleValueArgumentEntry);
argumentEntry.updateEntry(singleValueArgumentEntry, ctx);
} else {
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry);
}

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

@ -19,11 +19,18 @@ import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Data
public class EntityAggregationArgumentEntry implements ArgumentEntry {
@ -47,28 +54,63 @@ public class EntityAggregationArgumentEntry implements ArgumentEntry {
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
boolean updated = false;
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof EntityAggregationArgumentEntry entityAggEntry) {
aggIntervals.putAll(entityAggEntry.getAggIntervals());
return true;
} else if (entry instanceof SingleValueArgumentEntry singleValueArgEntry) {
long entryTs = singleValueArgEntry.getTs();
long argUpdateTs = System.currentTimeMillis();
for (Map.Entry<AggIntervalEntry, AggIntervalEntryStatus> aggIntervalEntry : aggIntervals.entrySet()) {
if (singleValueArgEntry.isForceResetPrevious()) {
aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs);
updated = true;
continue;
}
if (aggIntervalEntry.getKey().belongsToInterval(entryTs)) {
aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs);
return true;
}
long now = System.currentTimeMillis();
if (updateExistingIntervals(singleValueArgEntry, entryTs, now)) {
return true;
}
return createNewInterval(entryTs, now, ctx);
}
return false;
}
private boolean updateExistingIntervals(SingleValueArgumentEntry entry, long entryTs, long now) {
boolean updated = false;
for (Map.Entry<AggIntervalEntry, AggIntervalEntryStatus> aggIntervalEntry : aggIntervals.entrySet()) {
AggIntervalEntry interval = aggIntervalEntry.getKey();
AggIntervalEntryStatus status = aggIntervalEntry.getValue();
if (entry.isForceResetPrevious()) {
status.setLastArgsRefreshTs(now);
updated = true;
continue;
}
if (interval.belongsToInterval(entryTs)) {
status.setLastArgsRefreshTs(now);
return true;
}
}
return updated;
}
private boolean createNewInterval(long entryTs, long now, CalculatedFieldCtx ctx) {
if (!(ctx.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration config)) {
return false;
}
AggInterval interval = config.getInterval();
Watermark watermark = config.getWatermark();
long watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration());
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(entryTs), interval.getZoneId());
long startTs = interval.getDateTimeIntervalStartTs(zdt);
long endTs = interval.getDateTimeIntervalEndTs(zdt);
if (now - endTs > watermarkDuration) {
return false;
}
AggIntervalEntry newInterval = new AggIntervalEntry(startTs, endTs);
aggIntervals.computeIfAbsent(newInterval, i -> new AggIntervalEntryStatus(now));
return true;
}
@Override
public boolean isEmpty() {
return aggIntervals.isEmpty();

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

@ -154,8 +154,10 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
}
private void fillMissingIntervals() {
long now = System.currentTimeMillis();
ZoneId zoneId = interval.getZoneId();
long currentIntervalEndTs = interval.getCurrentIntervalEndTs();
long watermarkThresholdTs = now - watermarkDuration;
Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> intervals = getIntervals();
AggIntervalEntry lastIntervalEntry = intervals.keySet().stream().max(Comparator.comparing(AggIntervalEntry::getEndTs)).orElse(null);
@ -169,6 +171,13 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
while (nextEnd.toInstant().toEpochMilli() <= currentIntervalEndTs) {
long nextStartTs = nextStart.toInstant().toEpochMilli();
long nextEndTs = nextEnd.toInstant().toEpochMilli();
if (nextEndTs < watermarkThresholdTs) {
nextStart = nextEnd;
nextEnd = interval.getNextIntervalStart(nextStart);
continue;
}
AggIntervalEntry missing = new AggIntervalEntry(nextStartTs, nextEndTs);
arguments.forEach((argName, argumentEntry) -> {

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

@ -150,7 +150,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
clearRuleState = null;
}
}
log.debug("Initialized create rule states {} and clear rule state {} for {}", createRuleStates, clearRuleState, configuration);
log.debug("Initialized create rule states {} and clear rule state {} for {}. Restored: {}, reeval needed: {}",
createRuleStates, clearRuleState, configuration, restored, reevalNeeded);
if (reevalNeeded.get()) {
initCurrentAlarm(ctx);
@ -224,17 +225,17 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
protected boolean updateEntry(ArgumentEntry existingArgumentEntry, ArgumentEntry newArgumentEntry) {
protected boolean updateEntry(ArgumentEntry existingArgumentEntry, ArgumentEntry newArgumentEntry, CalculatedFieldCtx ctx) {
if (!(existingArgumentEntry instanceof SingleValueArgumentEntry existingEntry) ||
!(newArgumentEntry instanceof SingleValueArgumentEntry newEntry)) {
return super.updateEntry(existingArgumentEntry, newArgumentEntry);
return super.updateEntry(existingArgumentEntry, newArgumentEntry, ctx);
}
if (newEntry.getTs() < existingEntry.getTs()) {
if (existingEntry.isDefaultValue()) {
existingEntry.setTs(newEntry.getTs());
}
}
return super.updateEntry(existingEntry, newEntry);
return super.updateEntry(existingEntry, newEntry, ctx);
}
public void processAlarmAction(Alarm alarm, ActionType action) {

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

@ -262,6 +262,14 @@ public class AlarmRuleState {
}
}
public void setDurationCheckFuture(ScheduledFuture<?> durationCheckFuture) {
if (this.durationCheckFuture != null) {
log.warn("Setting new duration check future while previous is not null for state {}", this, new RuntimeException("stacktrace"));
this.durationCheckFuture.cancel(true);
}
this.durationCheckFuture = durationCheckFuture;
}
public boolean isEmpty() {
return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null;
}

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

@ -25,6 +25,7 @@ import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos;
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.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.HasLatestTs;
import java.util.Map;
@ -68,7 +69,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry, HasLatestTs {
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType());
}

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

@ -21,6 +21,7 @@ import org.thingsboard.script.api.tbel.TbelCfPropagationArg;
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.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.ArrayList;
import java.util.Collection;
@ -36,7 +37,7 @@ public class PropagationArgumentEntry implements ArgumentEntry {
private transient EntityId removed;
private transient boolean forceResetPrevious;
private transient boolean ignoreRemovedEntities;
private transient boolean syncWithDb;
public PropagationArgumentEntry() {
this.entityIds = new HashSet<>();
@ -59,7 +60,7 @@ public class PropagationArgumentEntry implements ArgumentEntry {
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (!(entry instanceof PropagationArgumentEntry updated)) {
throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType());
}
@ -69,14 +70,18 @@ public class PropagationArgumentEntry implements ArgumentEntry {
if (updated.getRemoved() != null) {
return entityIds.remove(updated.getRemoved());
}
if (updated.isIgnoreRemovedEntities()) {
Set<EntityId> updatedIds = updated.getEntityIds();
if (updatedIds.isEmpty()) {
if (updated.isSyncWithDb()) {
Set<EntityId> dbEntityIds = updated.getEntityIds();
if (dbEntityIds.isEmpty()) {
if (entityIds.isEmpty()) {
return false;
}
entityIds.clear();
return false;
return true;
}
entityIds.retainAll(updatedIds);
return checkAdded(updatedIds);
boolean retained = entityIds.retainAll(dbEntityIds);
boolean added = checkAdded(dbEntityIds);
return retained || added;
}
if (updated.isEmpty()) {
entityIds.clear();

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

@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@ -41,6 +42,7 @@ import java.util.Map;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
@Slf4j
public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState {
private CalculatedFieldProcessingService cfProcessingService;
@ -67,12 +69,17 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState
super.init(restored);
if (restored) {
cfProcessingService.fetchPropagationArgumentFromDb(ctx, entityId).ifPresent(fromDb -> {
fromDb.setIgnoreRemovedEntities(true);
fromDb.setSyncWithDb(true);
var updatedArgs = update(Map.of(PROPAGATION_CONFIG_ARGUMENT, fromDb), ctx);
if (updatedArgs.isEmpty()) {
return;
}
ctx.scheduleReevaluation(0L, actorCtx);
log.warn("[{}][{}] Propagation argument was out of sync during state restore and was reconciled with DB.", ctx.getCfId(), entityId);
var updatedPropagationArgument = (PropagationArgumentEntry) arguments.get(PROPAGATION_CONFIG_ARGUMENT);
if (updatedPropagationArgument.getAdded() != null) {
log.warn("[{}][{}] New propagation entities were detected during restore. Scheduling reevaluation for new entities...", ctx.getCfId(), entityId);
ctx.scheduleReevaluation(0L, actorCtx);
}
});
}
}

44
application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java

@ -27,6 +27,7 @@ import com.google.gson.reflect.TypeToken;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode;
import org.thingsboard.rule.engine.ai.TbAiNode;
import org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode;
import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode;
import org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode;
@ -167,6 +168,14 @@ public class EdgeMsgConstructorUtils {
);
public static final Map<EdgeVersion, Set<String>> EXCLUDED_NODES_BY_EDGE_VERSION = Map.of(
EdgeVersion.V_4_1_0,
Set.of(
TbAiNode.class.getName()
),
EdgeVersion.V_4_0_0,
Set.of(
TbAiNode.class.getName()
),
EdgeVersion.V_3_9_0,
Set.of(
TbCalculatedFieldsNode.class.getName()
@ -560,7 +569,7 @@ public class EdgeMsgConstructorUtils {
.setEntityIdMSB(entityId.getId().getMostSignificantBits())
.setEntityIdLSB(entityId.getId().getLeastSignificantBits())
.setEntityType(entityId.getEntityType().name());
long ts = getTs(entityData.getAsJsonObject());
long ts = extractTs(entityData.getAsJsonObject());
switch (actionType) {
case TIMESERIES_UPDATED:
try {
@ -613,8 +622,8 @@ public class EdgeMsgConstructorUtils {
return builder.build();
}
private static long getTs(JsonObject data) {
if (data.get("ts") != null && !data.get("ts").isJsonNull()) {
private static long extractTs(JsonObject data) {
if (data.has("ts") && data.get("ts").isJsonPrimitive()) {
return data.getAsJsonPrimitive("ts").getAsLong();
}
return System.currentTimeMillis();
@ -740,7 +749,7 @@ public class EdgeMsgConstructorUtils {
result.sort(Comparator.comparingLong(EdgeEvent::getSeqId));
return result;
} catch (Exception e) {
log.warn("Can't merge downlink duplicates, edgeEvents [{}]", edgeEvents, e);
log.info("Can't merge downlink duplicates. Sending downlinks without merge. Original edgeEvents [{}]", edgeEvents, e);
return edgeEvents;
}
}
@ -751,6 +760,9 @@ public class EdgeMsgConstructorUtils {
}
String bodyStr = JacksonUtil.toString(body);
var jsonObject = JsonParser.parseString(bodyStr).getAsJsonObject();
if (!jsonObject.has("ts")) {
return new AttrsTs(0L, List.of());
}
long ts = jsonObject.get("ts").getAsLong();
var kv = jsonObject.getAsJsonObject("kv");
List<AttributeKvEntry> attrs = JsonConverter.convertToAttributes(
@ -761,22 +773,24 @@ public class EdgeMsgConstructorUtils {
}
private static JsonNode filterAttributesBody(JsonNode body, Map<String, Long> latestByKey) {
if (body == null || latestByKey == null || latestByKey.isEmpty()) {
if (body == null) {
return null;
}
String bodyStr = JacksonUtil.toString(body);
JsonObject jsonObject = JsonParser.parseString(bodyStr).getAsJsonObject();
long ts = jsonObject.get("ts").getAsLong();
JsonObject kv = jsonObject.getAsJsonObject("kv");
for (Iterator<Map.Entry<String, JsonElement>> it = kv.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<String, JsonElement> e = it.next();
Long latestTs = latestByKey.get(e.getKey());
if (latestTs == null || !latestTs.equals(ts)) {
it.remove();
if (jsonObject.has("ts") && latestByKey != null && !latestByKey.isEmpty()) {
long ts = jsonObject.get("ts").getAsLong();
JsonObject kv = jsonObject.getAsJsonObject("kv");
for (Iterator<Map.Entry<String, JsonElement>> it = kv.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<String, JsonElement> e = it.next();
Long latestTs = latestByKey.get(e.getKey());
if (latestTs == null || !latestTs.equals(ts)) {
it.remove();
}
}
if (kv.isEmpty()) {
return null;
}
}
if (kv.isEmpty()) {
return null;
}
return JacksonUtil.toJsonNode(jsonObject.toString());
}

3
application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java

@ -30,7 +30,8 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
// This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.).
// The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release.
private static final List<String> SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0");
// TODO: don't check the "patch" number, since upgrade is not required for patch releases
private static final List<String> SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0", "4.2.1.1", "4.2.1.2");
private final ProjectInfo projectInfo;
private final JdbcTemplate jdbcTemplate;

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

@ -1467,7 +1467,7 @@ device:
pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}"
gateway:
# The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch
image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}"
image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}"
# Edges parameters
edges:

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

@ -20,9 +20,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
@ -76,7 +74,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
public void testSimpleCalculatedFieldWhenAllTelemetryPresent() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
postTelemetry(testDevice.getId(), "{\"temperature\":25}");
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}"));
postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"deviceTemperature\":40}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@ -259,15 +257,15 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
@Test
public void testSimpleCalculatedFieldWhenEntityIdIsProfile() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":40}"));
postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":40}");
AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class);
Asset asset1 = createAsset("Test asset 1", assetProfile.getId());
doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":11}"));
postAttributes(asset1.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":11}");
Asset asset2 = createAsset("Test asset 2", assetProfile.getId());
doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":12}"));
postAttributes(asset2.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":12}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(assetProfile.getId());
@ -316,7 +314,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z2.get(0).get("value").asText()).isEqualTo("52.0");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":25}"));
postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":25}");
await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -332,7 +330,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0");
});
doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":15}"));
postAttributes(asset1.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":15}");
await().alias("update asset 1 telemetry -> recalculate state only for asset 1").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -348,7 +346,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0");
});
doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":5}"));
postAttributes(asset2.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":5}");
await().alias("update asset 2 telemetry -> recalculate state only for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -365,7 +363,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
Asset asset3 = createAsset("Test asset 3", assetProfile.getId());
doPost("/api/plugins/telemetry/ASSET/" + asset3.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":13}"));
postAttributes(asset3.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":13}");
Asset finalAsset3 = asset3;
await().alias("add new entity to profile -> calculate state for new entity").atMost(TIMEOUT, TimeUnit.SECONDS)
@ -377,7 +375,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(z3.get(0).get("value").asText()).isEqualTo("38.0");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":20}"));
postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":20}");
await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -403,7 +401,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
asset3.setAssetProfileId(newAssetProfile.getId());
asset3 = doPost("/api/asset", asset3, Asset.class);
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":15}"));
postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":15}");
Asset updatedAsset3 = asset3;
await().alias("update device telemetry -> recalculate state for asset 1 and asset 2").atMost(TIMEOUT, TimeUnit.SECONDS)
@ -662,7 +660,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
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}"));
postTelemetry(testDevice.getId(), "{\"a\":10}");
await().alias("update telemetry -> save result with ts of 'a' argument").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -674,8 +672,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
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}"));
postTelemetry(testDevice.getId(), "{\"b\":20}");
postTelemetry(testDevice.getId(), "{\"c\":30}");
await().alias("update telemetry -> save result with latest ts of updated arguments").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -726,7 +724,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":1}"));
postTelemetry(testDevice.getId(), "{\"m\":1}");
await().alias("create CF -> ctx is initialized -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -740,7 +738,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
calculatedField.setConfiguration(config);
calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":2}"));
postTelemetry(testDevice.getId(), "{\"m\":2}");
await().alias("update CF -> ctx is not initialized -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -761,15 +759,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
// Restricted zone polygon (square)
String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"allowedZone\":" + allowedPolygon + "}")).andExpect(status().isOk());
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"allowedZone\":" + allowedPolygon + "}");
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"restrictedZone\":" + restrictedPolygon + "}");
// Initial device coordinates (inside Allowed, outside Restricted)
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}");
// --- Build CF: GEOFENCING ---
CalculatedField cf = new CalculatedField();
@ -817,8 +811,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
doDelete("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/SERVER_SCOPE?keys=allowedZonesStatus,restrictedZonesStatus", String.class);
// --- Update restrictedZone by 'restrictedZone' attribute update
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"restrictedZone\":" + restrictedPolygon + "}");
// --- Assert no transition ---
// --- Assert attributes updated with the same values for restrictedZones ---
@ -848,30 +841,17 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
Asset allowedZoneAsset = createAsset("Allowed Zone", null);
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk());
postAttributes(allowedZoneAsset.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygon + "}");
Asset restrictedZoneAsset = createAsset("Restricted Zone", null);
doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
postAttributes(restrictedZoneAsset.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + restrictedPolygon + "}");
// Relations from device to zones
EntityRelation deviceToAllowedZoneRelation = new EntityRelation();
deviceToAllowedZoneRelation.setFrom(device.getId());
deviceToAllowedZoneRelation.setTo(allowedZoneAsset.getId());
deviceToAllowedZoneRelation.setType("AllowedZone");
EntityRelation deviceToRestrictedZoneRelation = new EntityRelation();
deviceToRestrictedZoneRelation.setFrom(device.getId());
deviceToRestrictedZoneRelation.setTo(restrictedZoneAsset.getId());
deviceToRestrictedZoneRelation.setType("RestrictedZone");
doPost("/api/relation", deviceToAllowedZoneRelation).andExpect(status().isOk());
doPost("/api/relation", deviceToRestrictedZoneRelation).andExpect(status().isOk());
createEntityRelation(device.getId(), allowedZoneAsset.getId(), "AllowedZone");
createEntityRelation(device.getId(), restrictedZoneAsset.getId(), "RestrictedZone");
// Initial device coordinates (inside Allowed, outside Restricted)
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}");
// --- Build CF: GEOFENCING ---
CalculatedField cf = new CalculatedField();
@ -922,8 +902,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
// --- Move the device into Restricted zone (and outside Allowed) ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}"));
postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}");
// --- Assert transition (LEFT / ENTERED) ---
await().alias("transition evaluation after movement")
@ -965,19 +944,13 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
String allowedPolygonA = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
Asset allowedZoneA = createAsset("Allowed Zone A", null);
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneA.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonA + "}")).andExpect(status().isOk());
postAttributes(allowedZoneA.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygonA + "}");
// Relation from device to Allowed Zone A
EntityRelation relAllowedA = new EntityRelation();
relAllowedA.setFrom(device.getId());
relAllowedA.setTo(allowedZoneA.getId());
relAllowedA.setType("AllowedZone");
doPost("/api/relation", relAllowedA).andExpect(status().isOk());
createEntityRelation(device.getId(), allowedZoneA.getId(), "AllowedZone");
// Initial device coordinates: INSIDE Zone A
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")).andExpect(status().isOk());
postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}");
// --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh ---
CalculatedField cf = new CalculatedField();
@ -1024,8 +997,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
// --- Move device OUTSIDE Zone A (expect LEFT) ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}");
await().alias("outside zone A (LEFT)")
.atMost(TIMEOUT, TimeUnit.SECONDS)
@ -1042,21 +1014,15 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
String allowedPolygonB = "[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]";
Asset allowedZoneB = createAsset("Allowed Zone B", null);
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneB.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonB + "}")).andExpect(status().isOk());
postAttributes(allowedZoneB.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygonB + "}");
// Add a new relation
EntityRelation relAllowedB = new EntityRelation();
relAllowedB.setFrom(device.getId());
relAllowedB.setTo(allowedZoneB.getId());
relAllowedB.setType("AllowedZone");
doPost("/api/relation", relAllowedB).andExpect(status().isOk());
createEntityRelation(device.getId(), allowedZoneB.getId(), "AllowedZone");
awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF);
// --- Same coordinates as before, but now we expect INSIDE group status since a new zone is registered ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}");
// --- Assert dynamic refresh picks up a new relation and flips status back to INSIDE on the next telemetry update ---
await().alias("dynamic refresh rebinds allowedZones")
@ -1079,14 +1045,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
Asset asset2 = createAsset("Propagated Asset 2", null);
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel1).andExpect(status().isOk());
doPost("/api/relation", rel2).andExpect(status().isOk());
createEntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
createEntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
// Telemetry on device
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"temperature\":12.5, \"humidity\":85}")).andExpect(status().isOk());
postTelemetry(device.getId(), "{\"temperature\":12.5, \"humidity\":85}");
// --- Build CF: PROPAGATION with expression ---
CalculatedField cf = new CalculatedField();
@ -1136,8 +1099,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
doDelete(deleteUrl).andExpect(status().isOk());
doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk());
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"temperature\":25}")).andExpect(status().isOk());
postTelemetry(device.getId(), "{\"temperature\":25}");
// --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) ---
await().alias("propagation expr mode evaluation after temperature update")
@ -1160,10 +1122,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
Asset asset2 = createAsset("Propagated Asset 2", null);
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel1).andExpect(status().isOk());
doPost("/api/relation", rel2).andExpect(status().isOk());
createEntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
createEntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
// Telemetry on device
long ts = System.currentTimeMillis() - 300000L;
@ -1245,8 +1205,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
Asset asset3 = createAsset("Propagated Asset 3", null);
EntityRelation rel3 = new EntityRelation(asset3.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel3).andExpect(status().isOk());
createEntityRelation(asset3.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
// --- Assert propagated calculation (arguments-only mode after update) ---
await().alias("propagation args-only to new entity after relation creation")
@ -1266,7 +1225,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
@Test
public void testCalculatedFieldWhenTheSameTelemetryKeysUsed() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":5}"));
postTelemetry(testDevice.getId(), "{\"a\":5}");
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@ -1301,7 +1260,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("10");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}"));
postTelemetry(testDevice.getId(), "{\"a\":10}");
await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -1316,8 +1275,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
public void testCalculatedFieldWhenBatchOfTelemetrySent() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
long now = System.currentTimeMillis();
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":5, \"b\":10}}", now - TimeUnit.MINUTES.toMillis(3))));
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"b\":20}}", now - TimeUnit.MINUTES.toMillis(1))));
postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":5, \"b\":10}}", now - TimeUnit.MINUTES.toMillis(3)));
postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"b\":20}}", now - TimeUnit.MINUTES.toMillis(1)));
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
@ -1358,7 +1317,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(result.get("avgB").get(0).get("value").asText()).isEqualTo("15.0");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("""
postTelemetry(testDevice.getId(), String.format("""
[{
"ts": %s,
"values": {
@ -1377,7 +1336,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
"a": 8,
"b": 300
}
}]""", now - TimeUnit.MINUTES.toMillis(2), now, now - TimeUnit.MINUTES.toMillis(5))));
}]""", now - TimeUnit.MINUTES.toMillis(2), now, now - TimeUnit.MINUTES.toMillis(5)));
await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)

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

@ -55,6 +55,8 @@ import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTE
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest {
private final String TZ = "Europe/Kyiv";
private Tenant savedTenant;
@Before
@ -93,7 +95,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L);
createConsumptionCF(device.getId(), customInterval, null);
long interval = customInterval.getCurrentIntervalDurationMillis();
@ -113,7 +115,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
public void testCreateCfWithoutWatermark_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L);
createConsumptionCF(device.getId(), customInterval, null);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
@ -156,7 +158,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L);
Watermark watermark = new Watermark(10);
createConsumptionCF(device.getId(), customInterval, watermark);
@ -196,6 +198,51 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest
});
}
@Test
public void testSendFutureTelemetry_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval(TZ, 0L, 2L);
createConsumptionCF(device.getId(), customInterval, null);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long tsBeforeInterval = currentIntervalStartTs - 1000;
long tsInInterval_1 = currentIntervalStartTs + 1000;
long tsInInterval_2 = currentIntervalStartTs + 500;
long tsInInterval_3 = currentIntervalStartTs + 200;
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis();
await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133");
});
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", currentIntervalStartTs + 4500L));
await().alias("update telemetry that belongs to future interval -> check aggregation ")
.atMost(3 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("500");
assertThat(result.get("consumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L);
assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("500");
assertThat(result.get("avgConsumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L);
});
}
private CalculatedField createConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) {
Map<String, Argument> arguments = new HashMap<>();
Argument argument = new Argument();

2
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -1143,7 +1143,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void createEntityRelation(EntityId entityIdFrom, EntityId entityIdTo, String typeRelation) throws Exception {
EntityRelation relation = new EntityRelation(entityIdFrom, entityIdTo, typeRelation);
doPost("/api/relation", relation);
doPost("/api/relation", relation).andExpect(status().isOk());
}
protected void deleteEntityRelation(EntityRelation entityRelation) throws Exception {

2
application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java

@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
private DeviceProfileId mqttDeviceProfileId;
private DeviceProfileId coapDeviceProfileId;
@Value("${device.connectivity.gateway.image_version:3.7-stable}")
@Value("${device.connectivity.gateway.image_version:3.8-stable}")
private String gatewayImageVersion;
@Before

64
application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java

@ -17,6 +17,7 @@ package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@ -27,6 +28,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DataConstants;
@ -34,6 +36,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmSeverity;
@ -63,6 +66,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.common.data.query.KeyFilter;
import org.thingsboard.server.common.data.query.NumericFilterPredicate;
import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate;
import org.thingsboard.server.common.data.query.TsValue;
import org.thingsboard.server.common.data.queue.QueueStats;
@ -70,6 +74,8 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.edqs.util.EdqsRocksDb;
@ -216,6 +222,64 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
countByQueryAndCheck(countQuery, 97);
}
@Test
public void testEDQForSysAdmin() throws Exception {
loginSysAdmin();
ObjectNode tenantAttr = JacksonUtil.newObjectNode();
tenantAttr.put("attr", "tenantAttrValue");
doPost("/api/plugins/telemetry/TENANT/" + tenantId + "/attributes/" + AttributeScope.SERVER_SCOPE, tenantAttr);
loginTenantAdmin();
Device tenantDevice = new Device();
tenantDevice.setName("device " + StringUtils.randomAlphanumeric(10));
tenantDevice.setType("default");
tenantDevice = doPost("/api/device", tenantDevice, Device.class);
loginSysAdmin();
TenantProfile tenantProfile = doPost("/api/tenantProfile", createTenantProfile("Test tenant profile"), TenantProfile.class);
ObjectNode tenantProfileAttr = JacksonUtil.newObjectNode();
tenantProfileAttr.put("attr", "tenantProfileAttrValue");
doPost("/api/plugins/telemetry/TENANT_PROFILE/" + tenantProfile.getId() + "/attributes/" + AttributeScope.SERVER_SCOPE, tenantProfileAttr);
// check tenant telemetry is accessible for sysadmin
SingleEntityFilter filter = new SingleEntityFilter();
filter.setSingleEntity(AliasEntityId.fromEntityId(tenantId));
EntityDataSortOrder sortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC);
EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
List<EntityKey> entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
List<EntityKey> latestValues = List.of(new EntityKey(EntityKeyType.ATTRIBUTE, "attr"));
EntityDataQuery dataQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null);
PageData<EntityData> loadedTenants = findByQueryAndCheck(dataQuery, 1);
String retrievedTenantAttr = loadedTenants.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("attr").getValue();
assertThat(retrievedTenantAttr).isEqualTo("tenantAttrValue");
// check tenant profile telemetry is accessible for sysadmin
filter.setSingleEntity(AliasEntityId.fromEntityId(tenantProfile.getId()));
PageData<EntityData> loadedTenantProfiles = findByQueryAndCheck(dataQuery, 1);
String retrievedProfileAttr = loadedTenantProfiles.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("attr").getValue();
assertThat(retrievedProfileAttr).isEqualTo("tenantProfileAttrValue");
// check other tenant entities are prohibited
filter.setSingleEntity(AliasEntityId.fromEntityId(tenantDevice.getId()));
findByQueryAndCheck(dataQuery, 0);
}
private TenantProfile createTenantProfile(String name) {
TenantProfile tenantProfile = new TenantProfile();
tenantProfile.setName(name);
tenantProfile.setDescription(name + " Test");
TenantProfileData tenantProfileData = new TenantProfileData();
tenantProfileData.setConfiguration(new DefaultTenantProfileConfiguration());
tenantProfile.setProfileData(tenantProfileData);
tenantProfile.setDefault(false);
tenantProfile.setIsolatedTbRuleEngine(false);
return tenantProfile;
}
@Test
public void testTenantCountAlarmsByQuery() throws Exception {
loginTenantAdmin();

5
application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java

@ -49,8 +49,6 @@ import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.ota.OtaPackageType;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
@ -671,8 +669,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
.atMost(10, TimeUnit.SECONDS)
.until(() -> {
String urlTemplate = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/keys/attributes/" + scope;
List<String> actualKeys = doGetAsyncTyped(urlTemplate, new TypeReference<>() {
});
List<String> actualKeys = doGetAsyncTyped(urlTemplate, new TypeReference<>() {});
return actualKeys != null && !actualKeys.isEmpty() && actualKeys.contains(expectedKey);
});

2
application/src/test/java/org/thingsboard/server/edge/EdgeStatsIntegrationTest.java

@ -78,8 +78,6 @@ public class EdgeStatsIntegrationTest extends AbstractEdgeTest {
assertEquals(EXPECTED_MSGS_TMP_FAILED, counters.getMsgsTmpFailed().get());
});
Thread.sleep(1000);
// WHEN
edgeStatsService.reportStats();

4
application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java

@ -438,7 +438,7 @@ public class EdgeImitator {
Optional<T> result;
lock.lock();
try {
result = (Optional<T>) downlinkMsgs.stream().filter(downlinkMsg -> downlinkMsg.getClass().isAssignableFrom(tClass)).findAny();
result = (Optional<T>) downlinkMsgs.stream().filter(downlinkMsg -> tClass.isAssignableFrom(downlinkMsg.getClass())).findAny();
} finally {
lock.unlock();
}
@ -450,7 +450,7 @@ public class EdgeImitator {
List<T> result;
lock.lock();
try {
result = (List<T>) downlinkMsgs.stream().filter(downlinkMsg -> downlinkMsg.getClass().isAssignableFrom(tClass)).collect(Collectors.toList());
result = (List<T>) downlinkMsgs.stream().filter(downlinkMsg -> tClass.isAssignableFrom(downlinkMsg.getClass())).collect(Collectors.toList());
} finally {
lock.unlock();
}

25
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java

@ -18,6 +18,9 @@ package org.thingsboard.server.service.cf.ctx.state;
import io.hypersistence.utils.hibernate.type.json.internal.JacksonUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.geo.PerimeterDefinition;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.EntityId;
@ -33,6 +36,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
public class GeofencingValueArgumentEntryTest {
private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714"));
@ -46,6 +50,9 @@ public class GeofencingValueArgumentEntryTest {
private GeofencingArgumentEntry entry;
@Mock
private CalculatedFieldCtx ctx;
@BeforeEach
void setUp() {
entry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry));
@ -58,14 +65,14 @@ public class GeofencingValueArgumentEntryTest {
@Test
void testUpdateEntryWhenSingleEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry()))
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry(), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE");
}
@Test
void testUpdateEntryWhenRollingEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for geofencing argument entry: TS_ROLLING");
}
@ -74,7 +81,7 @@ public class GeofencingValueArgumentEntryTest {
void testUpdateEntryWithTheSameTs() {
BaseAttributeKvEntry differentValueSameTs = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 363L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueSameTs, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isFalse();
assertThat(entry.updateEntry(updated, ctx)).isFalse();
}
@Test
@ -83,7 +90,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry differentValueNewVersionIsNull = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, null);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsNull, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isTrue();
assertThat(entry.updateEntry(updated, ctx)).isTrue();
assertThat(entry.getValue()).isInstanceOf(Map.class);
Map<EntityId, GeofencingZoneState> value = (Map<EntityId, GeofencingZoneState>) entry.getValue();
@ -105,7 +112,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isTrue();
assertThat(entry.updateEntry(updated, ctx)).isTrue();
assertThat(entry.getValue()).isInstanceOf(Map.class);
Map<EntityId, GeofencingZoneState> value = (Map<EntityId, GeofencingZoneState>) entry.getValue();
@ -126,7 +133,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 154L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isFalse();
assertThat(entry.updateEntry(updated, ctx)).isFalse();
}
@Test
@ -134,7 +141,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry newTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 364L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, newTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isTrue();
assertThat(entry.updateEntry(updated, ctx)).isTrue();
}
@Test
@ -142,7 +149,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry oldTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 362L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, oldTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isFalse();
assertThat(entry.updateEntry(updated, ctx)).isFalse();
}
@Test
@ -150,7 +157,7 @@ public class GeofencingValueArgumentEntryTest {
final AssetId NEW_ZONE_ID = new AssetId(UUID.fromString("a3eacf1a-6af3-4e9f-87c4-502bb25c7dc3"));
BaseAttributeKvEntry newZone = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry, NEW_ZONE_ID, newZone));
assertThat(entry.updateEntry(updated)).isTrue();
assertThat(entry.updateEntry(updated, ctx)).isTrue();
}
@Test

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

@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfPropagationArg;
import org.thingsboard.server.common.data.id.AssetId;
@ -31,6 +34,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
public class PropagationArgumentEntryTest {
private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805"));
@ -39,6 +43,9 @@ public class PropagationArgumentEntryTest {
private PropagationArgumentEntry entry;
@Mock
private CalculatedFieldCtx ctx;
@BeforeEach
void setUp() {
List<EntityId> propagationEntityIds = new ArrayList<>();
@ -68,14 +75,14 @@ public class PropagationArgumentEntryTest {
@Test
void testUpdateEntryWhenSingleEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry()))
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry(), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE");
}
@Test
void testUpdateEntryWhenRollingEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING");
}
@ -85,7 +92,7 @@ public class PropagationArgumentEntryTest {
var newIds = new ArrayList<EntityId>(List.of(ENTITY_3_ID, ENTITY_1_ID));
var updated = new PropagationArgumentEntry(newIds);
boolean changed = entry.updateEntry(updated);
boolean changed = entry.updateEntry(updated, ctx);
assertThat(changed).isTrue();
assertThat(entry.getEntityIds()).containsExactlyElementsOf(newIds);
@ -95,7 +102,7 @@ public class PropagationArgumentEntryTest {
void testUpdateEntryClearsWhenNewEntryIsEmpty() {
var updatedEmpty = new PropagationArgumentEntry(List.of());
boolean changed = entry.updateEntry(updatedEmpty);
boolean changed = entry.updateEntry(updatedEmpty, ctx);
assertThat(changed).isTrue();
assertThat(entry.getEntityIds()).isEmpty();
@ -106,7 +113,7 @@ public class PropagationArgumentEntryTest {
var added = new PropagationArgumentEntry();
added.setAdded(List.of(ENTITY_3_ID));
boolean changed = entry.updateEntry(added);
boolean changed = entry.updateEntry(added, ctx);
assertThat(changed).isTrue();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID);
@ -118,7 +125,7 @@ public class PropagationArgumentEntryTest {
var added = new PropagationArgumentEntry();
added.setAdded(List.of(ENTITY_2_ID));
boolean changed = entry.updateEntry(added);
boolean changed = entry.updateEntry(added, ctx);
assertThat(changed).isFalse();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID);
@ -130,7 +137,7 @@ public class PropagationArgumentEntryTest {
var removed = new PropagationArgumentEntry();
removed.setRemoved(ENTITY_2_ID);
boolean changed = entry.updateEntry(removed);
boolean changed = entry.updateEntry(removed, ctx);
assertThat(changed).isTrue();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID);
@ -142,7 +149,7 @@ public class PropagationArgumentEntryTest {
var removed = new PropagationArgumentEntry();
removed.setRemoved(ENTITY_3_ID);
boolean changed = entry.updateEntry(removed);
boolean changed = entry.updateEntry(removed, ctx);
assertThat(changed).isFalse();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID);
@ -152,72 +159,72 @@ public class PropagationArgumentEntryTest {
@Test
void testUpdateEntryWhenPartitionStateRestoreAddsMissingIds() {
var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID));
restore.setIgnoreRemovedEntities(true);
restore.setSyncWithDb(true);
boolean changed = entry.updateEntry(restore);
boolean changed = entry.updateEntry(restore, ctx);
assertThat(changed).isTrue();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID);
assertThat(entry.getAdded()).containsExactly(ENTITY_3_ID);
assertThat(entry.getRemoved()).isNull();
assertThat(entry.isIgnoreRemovedEntities()).isFalse();
assertThat(entry.isSyncWithDb()).isFalse();
}
@Test
void testUpdateEntryWhenPartitionStateRestoreRemovesStaleIds() {
var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID));
restore.setIgnoreRemovedEntities(true);
restore.setSyncWithDb(true);
boolean changed = entry.updateEntry(restore);
boolean changed = entry.updateEntry(restore, ctx);
assertThat(changed).isFalse(); // expected no change, since we consider the removal of stale ids as no-op
assertThat(changed).isTrue(); // expected to be changed, so we re-check readiness for the state
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID);
assertThat(entry.getAdded()).isNull();
assertThat(entry.getRemoved()).isNull();
assertThat(entry.isIgnoreRemovedEntities()).isFalse();
assertThat(entry.isSyncWithDb()).isFalse();
}
@Test
void testUpdateEntryWhenPartitionStateRestoreAddsAndRemoves() {
var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_3_ID));
restore.setIgnoreRemovedEntities(true);
restore.setSyncWithDb(true);
boolean changed = entry.updateEntry(restore);
boolean changed = entry.updateEntry(restore, ctx);
assertThat(changed).isTrue();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_3_ID);
assertThat(entry.getAdded()).containsExactly(ENTITY_3_ID);
assertThat(entry.getRemoved()).isNull();
assertThat(entry.isIgnoreRemovedEntities()).isFalse();
assertThat(entry.isSyncWithDb()).isFalse();
}
@Test
void testUpdateEntryWhenPartitionStateRestoreNoChanges() {
var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_2_ID));
restore.setIgnoreRemovedEntities(true);
restore.setSyncWithDb(true);
boolean changed = entry.updateEntry(restore);
boolean changed = entry.updateEntry(restore, ctx);
assertThat(changed).isFalse();
assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID);
assertThat(entry.getAdded()).isNull();
assertThat(entry.getRemoved()).isNull();
assertThat(entry.isIgnoreRemovedEntities()).isFalse();
assertThat(entry.isSyncWithDb()).isFalse();
}
@Test
void testUpdateEntryWhenPartitionStateRestoreEmptySet() {
var restore = new PropagationArgumentEntry(List.of());
restore.setIgnoreRemovedEntities(true);
restore.setSyncWithDb(true);
boolean changed = entry.updateEntry(restore);
boolean changed = entry.updateEntry(restore, ctx);
assertThat(changed).isFalse(); // expected no change, since we consider the removal of stale ids as no-op
assertThat(changed).isTrue(); // expected to be changed, so we re-check readiness for the state
assertThat(entry.getEntityIds()).isEmpty();
assertThat(entry.getAdded()).isNull();
assertThat(entry.getRemoved()).isNull();
assertThat(entry.isIgnoreRemovedEntities()).isFalse();
assertThat(entry.isSyncWithDb()).isFalse();
}
@Test

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

@ -317,7 +317,49 @@ public class PropagationCalculatedFieldStateTest {
}
@Test
void testPropapagationStateInitWithRestoredSetToTrue() {
void testPropagationStateInitRestoredTrueNoDbChanges_noReevaluation() {
initCtxAndState(false);
// existing state already matches DB snapshot
Map<String, ArgumentEntry> initArgs = Map.of(
TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry,
HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry,
PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry
);
state.update(initArgs, ctx);
// DB returns the same set
when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(propagationArgEntry));
state.init(true);
verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId());
verify(ctx, never()).scheduleReevaluation(anyLong(), any());
}
@Test
void testPropagationStateInitRestoredTrueOnlyRemovals_noReevaluation() {
initCtxAndState(false);
Map<String, ArgumentEntry> initArgs = Map.of(
TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry,
HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry,
PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry
);
state.update(initArgs, ctx);
// DB snapshot contains only ASSET_ID_1 (ASSET_ID_2 should be removed)
var fromDb = new PropagationArgumentEntry(List.of(ASSET_ID_1));
when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb));
state.init(true);
verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId());
verify(ctx, never()).scheduleReevaluation(anyLong(), any());
}
@Test
void testPropagationStateIsNotReadyInitRestoredTrueAddedEntities_scheduleReevaluation() {
initCtxAndState(false);
Map<String, ArgumentEntry> initArgs = Map.of(
TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry,
@ -335,6 +377,75 @@ public class PropagationCalculatedFieldStateTest {
verify(ctx).scheduleReevaluation(0L, state.getActorCtx());
}
@Test
void testPropagationStateInitRestoredTrueAddedEntities_scheduleReevaluation() {
initCtxAndState(false);
// existing missing ASSET_ID_2
var existing = new PropagationArgumentEntry(List.of(ASSET_ID_1));
Map<String, ArgumentEntry> initArgs = Map.of(
TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry,
HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry,
PROPAGATION_CONFIG_ARGUMENT, existing
);
state.update(initArgs, ctx);
assertThat(state.isReady()).isTrue();
// DB snapshot contains both (ASSET_ID_2 should be "added")
var fromDb = new PropagationArgumentEntry(List.of(ASSET_ID_1, ASSET_ID_2));
when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb));
state.init(true);
verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId());
verify(ctx).scheduleReevaluation(0L, state.getActorCtx());
}
@Test
void testPropagationStateInitRestoredTrueDbEmpty_clearsState_noReevaluation() {
initCtxAndState(false);
// existing has something
Map<String, ArgumentEntry> initArgs = Map.of(
TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry,
HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry,
PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry
);
state.update(initArgs, ctx);
// DB snapshot empty -> clear
var fromDb = new PropagationArgumentEntry(Collections.emptyList());
when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb));
state.init(true);
verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId());
verify(ctx, never()).scheduleReevaluation(anyLong(), any());
}
@Test
void testPropagationStateInitRestoredTrueBothEmpty_noReevaluation() {
initCtxAndState(false);
// Existing propagation argument is empty
Map<String, ArgumentEntry> initArgs = Map.of(
TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry,
HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry,
PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())
);
state.update(initArgs, ctx);
assertThat(state.isReady()).isFalse();
// DB snapshot is also empty
var fromDb = new PropagationArgumentEntry(Collections.emptyList());
when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb));
state.init(true);
verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId());
verify(ctx, never()).scheduleReevaluation(anyLong(), any());
}
private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setTenantId(TENANT_ID);

15
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java

@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
@ -30,10 +33,14 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
public class RelatedEntitiesArgumentEntryTest {
private RelatedEntitiesArgumentEntry entry;
@Mock
private CalculatedFieldCtx ctx;
private final DeviceId device1 = new DeviceId(UUID.fromString("1984e5f4-9ff0-4187-84ae-e4438bba4c8a"));
private final DeviceId device2 = new DeviceId(UUID.fromString("937fc062-1a9d-438f-aa22-55a93fc908b7"));
@ -50,7 +57,7 @@ public class RelatedEntitiesArgumentEntryTest {
@Test
void testUpdateEntryWhenNotAggEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING);
}
@ -65,7 +72,7 @@ public class RelatedEntitiesArgumentEntryTest {
device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L))
), false);
assertThat(entry.updateEntry(relatedEntitiesArgumentEntry)).isTrue();
assertThat(entry.updateEntry(relatedEntitiesArgumentEntry, ctx)).isTrue();
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs();
assertThat(aggInputs.size()).isEqualTo(4);
@ -79,7 +86,7 @@ public class RelatedEntitiesArgumentEntryTest {
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L));
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue();
assertThat(entry.updateEntry(singleEntityArgumentEntry, ctx)).isTrue();
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs();
assertThat(aggInputs.size()).isEqualTo(3);
@ -90,7 +97,7 @@ public class RelatedEntitiesArgumentEntryTest {
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() {
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L));
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue();
assertThat(entry.updateEntry(singleEntityArgumentEntry, ctx)).isTrue();
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs();
assertThat(aggInputs.size()).isEqualTo(2);

23
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java

@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
@ -29,10 +32,14 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
public class SingleValueArgumentEntryTest {
private SingleValueArgumentEntry entry;
@Mock
private CalculatedFieldCtx ctx;
private final long ts = System.currentTimeMillis();
@BeforeEach
@ -47,48 +54,48 @@ public class SingleValueArgumentEntryTest {
@Test
void testUpdateEntryWhenRollingEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for single value argument entry: " + ArgumentEntryType.TS_ROLLING);
}
@Test
void testUpdateEntryWithTheSameTs() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L))).isFalse();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L), ctx)).isFalse();
}
@Test
void testUpdateEntryWithTheSameTsAndDifferentVersion() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 364L))).isTrue();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 364L), ctx)).isTrue();
}
@Test
void testUpdateEntryWhenNewVersionIsNull() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null))).isTrue();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null), ctx)).isTrue();
assertThat(entry.getValue()).isEqualTo(13L);
assertThat(entry.getVersion()).isNull();
}
@Test
void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L))).isTrue();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L), ctx)).isTrue();
assertThat(entry.getValue()).isEqualTo(18L);
assertThat(entry.getVersion()).isEqualTo(369L);
}
@Test
void testUpdateEntryWhenNewVersionIsLessThanCurrent() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L))).isFalse();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L), ctx)).isFalse();
}
@Test
void testUpdateEntryWhenValueWasNotChanged() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L))).isTrue();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L), ctx)).isTrue();
}
@Test
void testUpdateEntryWithOldTs() {
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L))).isFalse();
assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L), ctx)).isFalse();
}
@Test

17
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java

@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
@ -26,10 +29,14 @@ import java.util.TreeMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
public class TsRollingArgumentEntryTest {
private TsRollingArgumentEntry entry;
@Mock
private CalculatedFieldCtx ctx;
private final long ts = System.currentTimeMillis();
@BeforeEach
@ -51,7 +58,7 @@ public class TsRollingArgumentEntryTest {
void testUpdateEntryWhenSingleValueEntryPassed() {
SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new DoubleDataEntry("key", 23.0), 123L);
assertThat(entry.updateEntry(newEntry)).isTrue();
assertThat(entry.updateEntry(newEntry, ctx)).isTrue();
assertThat(entry.getTsRecords()).hasSize(4);
assertThat(entry.getTsRecords().get(ts - 10)).isEqualTo(23.0);
}
@ -64,7 +71,7 @@ public class TsRollingArgumentEntryTest {
values.put(ts - 5, 1.0);
newEntry.setTsRecords(values);
assertThat(entry.updateEntry(newEntry)).isTrue();
assertThat(entry.updateEntry(newEntry, ctx)).isTrue();
assertThat(entry.getTsRecords()).hasSize(5);
assertThat(entry.getTsRecords()).isEqualTo(Map.of(
ts - 40, 10.0,
@ -79,7 +86,7 @@ public class TsRollingArgumentEntryTest {
void testUpdateEntryWhenValueIsNotNumber() {
SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new StringDataEntry("key", "string"), 123L);
assertThat(entry.updateEntry(newEntry)).isTrue();
assertThat(entry.updateEntry(newEntry, ctx)).isTrue();
assertThat(entry.getTsRecords().get(ts - 10)).isNaN();
}
@ -93,7 +100,7 @@ public class TsRollingArgumentEntryTest {
newEntry.setTsRecords(values);
entry = new TsRollingArgumentEntry(3, 30000L);
assertThat(entry.updateEntry(newEntry)).isTrue();
assertThat(entry.updateEntry(newEntry, ctx)).isTrue();
assertThat(entry.getTsRecords()).hasSize(1);
assertThat(entry.getTsRecords()).isEqualTo(Map.of(
ts - 5, 0.0
@ -111,7 +118,7 @@ public class TsRollingArgumentEntryTest {
newEntry.setTsRecords(values);
entry = new TsRollingArgumentEntry(3, 30000L);
assertThat(entry.updateEntry(newEntry)).isTrue();
assertThat(entry.updateEntry(newEntry, ctx)).isTrue();
assertThat(entry.getTsRecords()).hasSize(3);
assertThat(entry.getTsRecords()).isEqualTo(Map.of(
ts - 18, 0.0,

25
application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java

@ -230,10 +230,35 @@ public class EdgeMsgConstructorUtilsTest {
Assertions.assertEquals(8, getIntValue(assetMergedAttrAD.getBody(), "d"));
}
@Test
public void testMergeDownlinkDuplicates_attrBodyHasNoTs_returnOriginalList() {
UUID deviceId = UUID.randomUUID();
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
var deviceAttrUpdate1 = createEdgeEvent(tenantId, 1, EdgeEventActionType.ATTRIBUTES_UPDATED,
deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":1,\"b\":1,\"d\":1}"));
var deviceAttrUpdate2 = createEdgeEvent(tenantId, 2, EdgeEventActionType.ATTRIBUTES_UPDATED,
deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":2,\"b\":2,\"c\":2}"));
var deviceAttrUpdate3 = createEdgeEvent(tenantId, 3, EdgeEventActionType.ATTRIBUTES_UPDATED,
deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":3,\"d\":3}"));
List<EdgeEvent> input = List.of(deviceAttrUpdate1, deviceAttrUpdate2, deviceAttrUpdate3);
List<EdgeEvent> merged = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(input);
Assertions.assertEquals(3, merged.size());
Assertions.assertEquals(deviceAttrUpdate1, merged.get(0));
Assertions.assertEquals(deviceAttrUpdate2, merged.get(1));
Assertions.assertEquals(deviceAttrUpdate3, merged.get(2));
}
private Integer getIntValue(JsonNode body, String key) {
return body.get("kv").get(key) != null ? body.get("kv").get(key).asInt() : null;
}
private static JsonNode createAttrBodyWithoutTs(String kvJson) {
return JacksonUtil.toJsonNode("{\"kv\":" + kvJson + "}");
}
private static JsonNode createAttrBody(long ts, String kvJson) {
return JacksonUtil.toJsonNode("{\"ts\":" + ts + ",\"kv\":" + kvJson + "}");
}

40
application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java

@ -107,6 +107,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getSuccessfulCount()).isEqualTo(tasksCount);
assertThat(job.getResult().getResults()).isEmpty();
assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount);
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
});
}
@ -133,6 +135,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(failure.getError()).isEqualTo("error3"); // last error
});
assertThat(jobResult.getCompletedCount()).isEqualTo(jobResult.getTotalCount());
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
});
}
@ -168,6 +172,9 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getDiscardedCount()).isBetween(1, tasksCount);
assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount);
assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount);
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
assertThat(job.getResult().getCancellationTs()).isPositive();
});
}
@ -194,6 +201,9 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getTotalCount()).isEqualTo(1);
assertThat(job.getResult().getDiscardedCount()).isEqualTo(1);
assertThat(job.getResult().getFailedCount()).isZero();
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
assertThat(job.getResult().getCancellationTs()).isPositive();
});
}
@ -222,6 +232,9 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getDiscardedCount()).isBetween(1, tasksCount - 1);
assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount);
assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount);
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
assertThat(job.getResult().getCancellationTs()).isPositive();
});
}
@ -275,6 +288,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount);
assertThat(job.getEntityId()).isEqualTo(jobEntity.getId());
assertThat(job.getEntityName()).isEqualTo(jobEntity.getName());
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
}
});
@ -385,6 +400,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getTotalCount()).isEqualTo(submittedTasks);
assertThat(job.getResult().getFailedCount()).isZero();
assertThat(job.getResult().getDiscardedCount()).isZero();
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
});
}
@ -425,6 +442,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(job.getResult().getTotalCount()).isEqualTo(totalTasksCount);
assertThat(job.getResult().getResults()).isEmpty();
assertThat(job.getConfiguration().getToReprocess()).isNullOrEmpty();
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
});
}
@ -449,6 +468,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks);
assertThat(jobResult.getFailedCount()).isEqualTo(failedTasks + permanentlyFailedTasks);
assertThat(jobResult.getTotalCount()).isEqualTo(totalTasksCount);
assertThat(jobResult.getStartTs()).isPositive();
assertThat(jobResult.getFinishTs()).isPositive();
List<DummyTaskFailure> failures = getFailures(jobResult);
for (int i = 0, taskNumber = successfulTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) {
@ -467,6 +488,8 @@ public class JobManagerTest extends AbstractControllerTest {
assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks + failedTasks);
assertThat(jobResult.getFailedCount()).isEqualTo(permanentlyFailedTasks);
assertThat(jobResult.getTotalCount()).isEqualTo(totalTasksCount);
assertThat(jobResult.getStartTs()).isPositive();
assertThat(jobResult.getFinishTs()).isPositive();
List<DummyTaskFailure> failures = getFailures(jobResult);
for (int i = 0, taskNumber = successfulTasks + failedTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) {
@ -478,6 +501,23 @@ public class JobManagerTest extends AbstractControllerTest {
});
}
@Test
public void testSubmitJob_zeroTasks() {
JobId jobId = submitJob(DummyJobConfiguration.builder()
.successfulTasksCount(0)
.build()).getId();
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
Job job = findJobById(jobId);
assertThat(job.getStatus()).isEqualTo(JobStatus.COMPLETED);
assertThat(job.getResult().getSuccessfulCount()).isEqualTo(0);
assertThat(job.getResult().getResults()).isEmpty();
assertThat(job.getResult().getCompletedCount()).isEqualTo(0);
assertThat(job.getResult().getStartTs()).isPositive();
assertThat(job.getResult().getFinishTs()).isPositive();
});
}
private Job submitJob(DummyJobConfiguration configuration) {
return submitJob(configuration, "test-job");
}

21
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java

@ -289,6 +289,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
@Override
public String getObjectIdByKeyNameFromProfile(LwM2mClient client, String keyName) {
Lwm2mDeviceProfileTransportConfiguration profile = getProfile(client.getRegistration());
if (profile == null) throw new IllegalArgumentException(keyName + " is not configured in the device profile! Device profile is null");
for (Map.Entry<String, String> entry : profile.getObserveAttr().getKeyName().entrySet()) {
String k = entry.getKey();
String v = entry.getValue();
@ -347,6 +348,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
PowerMode powerMode = lwM2MClient.getPowerMode();
if (powerMode == null) {
Lwm2mDeviceProfileTransportConfiguration deviceProfile = getProfile(lwM2MClient.getRegistration());
if (deviceProfile == null) return null;
powerMode = deviceProfile.getClientLwM2mSettings().getPowerMode();
}
return powerMode;
@ -377,8 +379,8 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
}
}
} else {
log.warn("Device profile not found! The device profile ID is null. Return Lwm2mDeviceProfileTransportConfiguration with default.");
result = new Lwm2mDeviceProfileTransportConfiguration();
log.warn("Device profile not found! The device profile ID is null. Return Lwm2mDeviceProfileTransportConfiguration with null.");
result = null;
}
return result;
}
@ -413,6 +415,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
OtherConfiguration profileSettings = null;
if (powerMode == null && client.getProfileId() != null) {
var clientProfile = getProfile(client.getRegistration());
if (clientProfile == null) return true;
profileSettings = clientProfile.getClientLwM2mSettings();
powerMode = profileSettings.getPowerMode();
}
@ -459,8 +462,10 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
OtherConfiguration profileSettings = null;
if (powerMode == null && client.getProfileId() != null) {
var clientProfile = getProfile(client.getRegistration());
profileSettings = clientProfile.getClientLwM2mSettings();
powerMode = profileSettings.getPowerMode();
if (clientProfile != null) {
profileSettings = clientProfile.getClientLwM2mSettings();
powerMode = profileSettings.getPowerMode();
}
}
if (powerMode == null || PowerMode.DRX.equals(powerMode)) {
client.updateLastUplinkTime();
@ -515,9 +520,11 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
timeout = client.getEdrxCycle();
} else {
var clientProfile = getProfile(client.getRegistration());
OtherConfiguration clientLwM2mSettings = clientProfile.getClientLwM2mSettings();
if (PowerMode.E_DRX.equals(clientLwM2mSettings.getPowerMode())) {
timeout = clientLwM2mSettings.getEdrxCycle();
if (clientProfile != null) {
OtherConfiguration clientLwM2mSettings = clientProfile.getClientLwM2mSettings();
if (PowerMode.E_DRX.equals(clientLwM2mSettings.getPowerMode())) {
timeout = clientLwM2mSettings.getEdrxCycle();
}
}
}
if (timeout == null || timeout == 0L) {

14
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java

@ -200,7 +200,8 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl
attributesToFetch.add(SOFTWARE_URL);
}
var clientSettings = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings();
var clientProfile = clientContext.getProfile(client.getRegistration());
var clientSettings = clientProfile != null ? clientProfile.getClientLwM2mSettings() : null;
if (clientSettings != null) {
initFwStrategy(client, clientSettings);
initSwStrategy(client, clientSettings);
@ -532,7 +533,8 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl
} else {
strategy = info.getDeliveryMethod() == FirmwareDeliveryMethod.PULL.code ? LwM2MFirmwareUpdateStrategy.OBJ_5_TEMP_URL : LwM2MFirmwareUpdateStrategy.OBJ_5_BINARY;
}
Boolean useObject19ForOtaInfo = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings().getUseObject19ForOtaInfo();
var clientProfile = clientContext.getProfile(client.getRegistration());
Boolean useObject19ForOtaInfo = clientProfile != null ? clientProfile.getClientLwM2mSettings().getUseObject19ForOtaInfo() : null;
if (useObject19ForOtaInfo != null && useObject19ForOtaInfo){
sendInfoToObject19ForOta(client, FW_INFO_19_INSTANCE_ID, response, otaPackageId);
}
@ -558,7 +560,8 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl
if (TransportProtos.ResponseStatus.SUCCESS.equals(response.getResponseStatus())) {
UUID otaPackageId = new UUID(response.getOtaPackageIdMSB(), response.getOtaPackageIdLSB());
LwM2MSoftwareUpdateStrategy strategy = info.getStrategy();
Boolean useObject19ForOtaInfo = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings().getUseObject19ForOtaInfo();
var clientProfile = clientContext.getProfile(client.getRegistration());
Boolean useObject19ForOtaInfo = clientProfile != null ? clientProfile.getClientLwM2mSettings().getUseObject19ForOtaInfo() : null;
if (useObject19ForOtaInfo != null && useObject19ForOtaInfo){
sendInfoToObject19ForOta(client, SW_INFO_19_INSTANCE_ID, response, otaPackageId);
}
@ -647,8 +650,9 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl
LwM2MClientSwOtaInfo info = otaInfoStore.getSw(endpoint);
if (info == null) {
var profile = clientContext.getProfile(client.getRegistration());
info = new LwM2MClientSwOtaInfo(endpoint, profile.getClientLwM2mSettings().getSwUpdateResource(),
LwM2MSoftwareUpdateStrategy.fromStrategySwByCode(profile.getClientLwM2mSettings().getSwUpdateStrategy()));
OtherConfiguration clientLwM2mSettings = profile == null ? new OtherConfiguration() : profile.getClientLwM2mSettings();
info = new LwM2MClientSwOtaInfo(endpoint, clientLwM2mSettings.getSwUpdateResource(),
LwM2MSoftwareUpdateStrategy.fromStrategySwByCode(clientLwM2mSettings.getSwUpdateStrategy()));
update(info);
}
return info;

69
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java

@ -420,7 +420,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
});
if (!clients.isEmpty()) {
var oldProfile = clientContext.getProfile(clients.get(0).getRegistration());
this.onDeviceProfileUpdate(clients, oldProfile, deviceProfile);
if (oldProfile != null) this.onDeviceProfileUpdate(clients, oldProfile, deviceProfile);
}
} catch (Exception e) {
log.warn("[{}] failed to update profile: {} [{}]", deviceProfile.getId(), e.getMessage(), deviceProfile);
@ -725,8 +725,10 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
private void onDeviceUpdate(LwM2mClient lwM2MClient, Device device, Optional<DeviceProfile> deviceProfileOpt) {
var oldProfile = clientContext.getProfile(lwM2MClient.getRegistration());
deviceProfileOpt.ifPresent(deviceProfile -> this.onDeviceProfileUpdate(Collections.singletonList(lwM2MClient), oldProfile, deviceProfile));
lwM2MClient.onDeviceUpdate(device, deviceProfileOpt);
if (oldProfile != null) {
deviceProfileOpt.ifPresent(deviceProfile -> this.onDeviceProfileUpdate(Collections.singletonList(lwM2MClient), oldProfile, deviceProfile));
lwM2MClient.onDeviceUpdate(device, deviceProfileOpt);
}
}
/**
@ -740,45 +742,12 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
Set<String> paths = updateResource.getPaths();
ResultsAddKeyValueProto results = new ResultsAddKeyValueProto();
var profile = clientContext.getProfile(registration);
List<TransportProtos.KeyValueProto> resultAttributes = new ArrayList<>();
Set<String> attributes = profile.getObserveAttr().getAttribute().stream()
.filter(paths::contains)
.collect(Collectors.toSet());
if (!attributes.isEmpty()){
attributes.stream()
.map(attr -> this.getKvToThingsBoard(attr, registration))
.filter(Objects::nonNull)
.forEach(resultAttributes::add);
}
List<TransportProtos.KeyValueProto> resultTelemetries = new ArrayList<>();
Set<String> telemetries = profile.getObserveAttr().getTelemetry().stream()
.filter(paths::contains)
.collect(Collectors.toSet());
if (!telemetries.isEmpty()){
telemetries.stream()
.map(telemetry -> this.getKvToThingsBoard(telemetry, registration))
.filter(Objects::nonNull)
.forEach(resultTelemetries::add);
}
if (resultAttributes.size() > 0) {
results.setResultAttributes(resultAttributes);
}
if (resultTelemetries.size() > 0) {
results.setResultTelemetries(resultTelemetries);
}
return results;
}
private ResultsAddKeyValueProto getParametersFromProfile(Registration registration, Set<String> path) {
if (!path.isEmpty()) {
ResultsAddKeyValueProto results = new ResultsAddKeyValueProto();
var profile = clientContext.getProfile(registration);
if (profile != null) {
List<TransportProtos.KeyValueProto> resultAttributes = new ArrayList<>();
Set<String> attributes = profile.getObserveAttr().getAttribute().stream()
.map(LwM2MTransportUtil::fromVersionedIdToObjectId)
.filter(path::contains)
.filter(paths::contains)
.collect(Collectors.toSet());
if (!attributes.isEmpty()){
if (!attributes.isEmpty()) {
attributes.stream()
.map(attr -> this.getKvToThingsBoard(attr, registration))
.filter(Objects::nonNull)
@ -786,10 +755,9 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
}
List<TransportProtos.KeyValueProto> resultTelemetries = new ArrayList<>();
Set<String> telemetries = profile.getObserveAttr().getTelemetry().stream()
.map(LwM2MTransportUtil::fromVersionedIdToObjectId)
.filter(path::contains)
.filter(paths::contains)
.collect(Collectors.toSet());
if (!telemetries.isEmpty()){
if (!telemetries.isEmpty()) {
telemetries.stream()
.map(telemetry -> this.getKvToThingsBoard(telemetry, registration))
.filter(Objects::nonNull)
@ -801,14 +769,15 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
if (resultTelemetries.size() > 0) {
results.setResultTelemetries(resultTelemetries);
}
return results;
}
return null;
return results;
}
private TransportProtos.KeyValueProto getKvToThingsBoard(String pathIdVer, Registration registration) {
LwM2mClient lwM2MClient = this.clientContext.getClientByEndpoint(registration.getEndpoint());
Map<String, String> names = clientContext.getProfile(lwM2MClient.getRegistration()).getObserveAttr().getKeyName();
var clientProfile = clientContext.getProfile(lwM2MClient.getRegistration());
if (clientProfile == null) return null;
Map<String, String> names = clientProfile.getObserveAttr().getKeyName();
if (names != null && names.containsKey(pathIdVer)) {
String resourceName = names.get(pathIdVer);
if (resourceName != null && !resourceName.isEmpty()) {
@ -899,10 +868,12 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
private void onDeviceProfileUpdate(List<LwM2mClient> clients, Lwm2mDeviceProfileTransportConfiguration oldProfileTransportConfiguration, DeviceProfile deviceProfile) {
if (clientContext.profileUpdate(deviceProfile) != null) {
var newProfileTransportConfiguration = clientContext.getProfile(clients.get(0).getRegistration());
ParametersUpdateAnalyzeResult parametersUpdate = getParametersUpdate(oldProfileTransportConfiguration, newProfileTransportConfiguration);
ParametersObserveAnalyzeResult parametersObserve = getParametersObserve(oldProfileTransportConfiguration.getObserveAttr(), newProfileTransportConfiguration.getObserveAttr(), deviceProfile.getId().getId());
compareAndSetWriteAttributesObservations(clients, parametersUpdate, parametersObserve);
updateValueOta(clients, newProfileTransportConfiguration, oldProfileTransportConfiguration);
if (newProfileTransportConfiguration != null) {
ParametersUpdateAnalyzeResult parametersUpdate = getParametersUpdate(oldProfileTransportConfiguration, newProfileTransportConfiguration);
ParametersObserveAnalyzeResult parametersObserve = getParametersObserve(oldProfileTransportConfiguration.getObserveAttr(), newProfileTransportConfiguration.getObserveAttr(), deviceProfile.getId().getId());
compareAndSetWriteAttributesObservations(clients, parametersUpdate, parametersObserve);
updateValueOta(clients, newProfileTransportConfiguration, oldProfileTransportConfiguration);
}
}
}

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java

@ -85,7 +85,7 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService
private String mqttsPemCertFile;
@Value("${device.connectivity.coaps.pem_cert_file:}")
private String coapsPemCertFile;
@Value("${device.connectivity.gateway.image_version:3.7-stable}")
@Value("${device.connectivity.gateway.image_version:3.8-stable}")
private String gatewayImageVersion;
@Override

2
dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java

@ -135,7 +135,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
long startNs = System.nanoTime();
PageData<EntityData> result;
if (edqsService.isApiEnabled() && validForEdqs(query)) {
if (edqsService.isApiEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) {
EdqsRequest request = EdqsRequest.builder()
.entityDataQuery(query)
.build();

3
dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java

@ -154,6 +154,9 @@ public class DefaultJobService extends AbstractEntityService implements JobServi
job.setStatus(COMPLETED);
publishEvent = true;
}
if (result.getTotalCount() == 0 && lastFinishTs == 0) {
lastFinishTs = System.currentTimeMillis();
}
result.setFinishTs(lastFinishTs);
job.getConfiguration().setToReprocess(null);
}

3
dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java

@ -540,7 +540,8 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
}
private String buildPermissionQuery(SqlQueryContext ctx, EntityFilter entityFilter) {
if (ctx.isIgnorePermissionCheck()) {
if (ctx.isIgnorePermissionCheck() || (ctx.getTenantId().isSysTenantId() &&
(ctx.getEntityType() == EntityType.TENANT || ctx.getEntityType() == EntityType.TENANT_PROFILE))) {
return "1=1";
}
switch (entityFilter.getType()) {

5
dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java

@ -72,6 +72,7 @@ import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException;
import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.service.validator.DeviceCredentialsDataValidator;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import java.nio.ByteBuffer;
@ -105,6 +106,8 @@ public class DeviceServiceTest extends AbstractServiceTest {
@Autowired
TenantProfileService tenantProfileService;
@Autowired
TbTenantProfileCache tenantProfileCache;
@Autowired
private CalculatedFieldService calculatedFieldService;
@Autowired
private PlatformTransactionManager platformTransactionManager;
@ -150,6 +153,7 @@ public class DeviceServiceTest extends AbstractServiceTest {
TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId);
defaultTenantProfile.getProfileData().setConfiguration(DefaultTenantProfileConfiguration.builder().maxDevices(Long.MAX_VALUE).build());
tenantProfileService.saveTenantProfile(tenantId, defaultTenantProfile);
tenantProfileCache.evict(defaultTenantProfile.getId());
Device device = this.saveDevice(tenantId, "My device");
deleteDevice(tenantId, device);
@ -185,6 +189,7 @@ public class DeviceServiceTest extends AbstractServiceTest {
TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId);
defaultTenantProfile.getProfileData().setConfiguration(DefaultTenantProfileConfiguration.builder().maxDevices(1).build());
tenantProfileService.saveTenantProfile(tenantId, defaultTenantProfile);
tenantProfileCache.evict(defaultTenantProfile.getId());
Assert.assertEquals(0, deviceService.countByTenantId(tenantId));

4
dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java

@ -38,6 +38,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import java.nio.ByteBuffer;
@ -75,6 +76,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
OtaPackageService otaPackageService;
@Autowired
TenantProfileService tenantProfileService;
@Autowired
TbTenantProfileCache tenantProfileCache;
@Before
public void before() {
@ -95,6 +98,7 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId);
defaultTenantProfile.getProfileData().setConfiguration(DefaultTenantProfileConfiguration.builder().maxOtaPackagesInBytes(DATA_SIZE).build());
tenantProfileService.saveTenantProfile(tenantId, defaultTenantProfile);
tenantProfileCache.evict(defaultTenantProfile.getId());
Assert.assertEquals(0, otaPackageService.sumDataSizeByTenantId(tenantId));

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

@ -485,7 +485,7 @@ public class TestRestClient {
.as(DeviceProfile.class);
}
public void deleteDeviseProfile(DeviceProfileId deviceProfileId) {
public void deleteDeviceProfile(DeviceProfileId deviceProfileId) {
given().spec(requestSpec)
.delete("/api/deviceProfile/{deviceProfileId}", deviceProfileId.getId())
.then()

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

@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.apache.commons.lang3.RandomStringUtils;
import org.testng.annotations.AfterClass;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@ -81,20 +82,22 @@ public class CalculatedFieldTest extends AbstractContainerTest {
public final int TIMEOUT = 60;
public final int POLL_INTERVAL = 1;
private final String deviceToken = "zmzURIVRsq3lvnTP2XBE";
private final String exampleScript = """
var avgTemperature = temperature.mean(); // Get average temperature
var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin
// Estimate air pressure based on altitude
var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);
// Air density formula
var airDensity = pressure / (287.05 * temperatureK);
return {
"airDensity": toFixed(airDensity, 2)
}
""";
private final String exampleScript = "var avgTemperature = temperature.mean(); // Get average temperature\n" +
" var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin\n" +
"\n" +
" // Estimate air pressure based on altitude\n" +
" var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);\n" +
"\n" +
" // Air density formula\n" +
" var airDensity = pressure / (287.05 * temperatureK);\n" +
"\n" +
" return {\n" +
" \"airDensity\": toFixed(airDensity, 2)\n" +
" };";
private final String deviceToken = "zmzURIVRsq3lvnTP2XBE";
private TenantId tenantId;
private UserId tenantAdminId;
@ -117,26 +120,28 @@ public class CalculatedFieldTest extends AbstractContainerTest {
tenantId = testRestClient.postTenant(EntityPrototypes.defaultTenantPrototype("Tenant")).getId();
tenantAdminId = testRestClient.createUserAndLogin(defaultTenantAdmin(tenantId, "tenantAdmin@thingsboard.org"), "tenant");
}
deviceProfileId = testRestClient.postDeviceProfile(defaultDeviceProfile("Device Profile 1")).getId();
device = testRestClient.postDevice(deviceToken, createDevice("Device 1", deviceProfileId));
@BeforeMethod
public void beforeMethod() {
testRestClient.getAndSetUserToken(tenantAdminId);
assetProfileId = testRestClient.postAssetProfile(defaultAssetProfile("Asset Profile 1")).getId();
asset = testRestClient.postAsset(createAsset("Asset 1", assetProfileId));
deviceProfileId = testRestClient.postDeviceProfile(defaultDeviceProfile("Device Profile")).getId();
device = testRestClient.postDevice(deviceToken, createDevice("Device", deviceProfileId));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
testRestClient.postTelemetryAttribute(device.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}"));
assetProfileId = testRestClient.postAssetProfile(defaultAssetProfile("Asset Profile")).getId();
asset = testRestClient.postAsset(createAsset("Asset", assetProfileId));
}
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.32}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.86}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":73.58}"));
@AfterMethod
public void tearDown() {
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetryAttribute(asset.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}"));
}
testRestClient.deleteDeviceIfExists(device.getId());
testRestClient.deleteDeviceProfile(deviceProfileId);
@BeforeMethod
public void beforeMethod() {
testRestClient.login("sysadmin@thingsboard.org", "sysadmin");
testRestClient.deleteAsset(asset.getId());
testRestClient.deleteAssetProfile(assetProfileId);
}
@AfterClass
@ -148,12 +153,10 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testPerformInitialCalculationForSimpleType() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
CalculatedField savedCalculatedField = createSimpleCalculatedField();
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId());
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -169,10 +172,21 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testChangeConfigArgument() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
testRestClient.postTelemetryAttribute(device.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}"));
CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId());
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0");
});
CalculatedField savedCalculatedField = createSimpleCalculatedField();
assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue();
Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T");
@ -193,10 +207,19 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testChangeConfigOutput() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
CalculatedField savedCalculatedField = createSimpleCalculatedField();
CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId());
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0");
});
AttributesOutput output = new AttributesOutput();
output.setScope(SERVER_SCOPE);
@ -219,12 +242,21 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testChangeConfigExpression() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
CalculatedField savedCalculatedField = createSimpleCalculatedField();
CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId());
assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue();
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0");
});
savedCalculatedField.setName("F to C");
((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("(T - 32) / 1.8");
testRestClient.postCalculatedField(savedCalculatedField);
@ -243,10 +275,19 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testTelemetryUpdated() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId());
CalculatedField savedCalculatedField = createSimpleCalculatedField();
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0");
});
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":30}"));
@ -264,8 +305,8 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testEntityIdIsProfile() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
CalculatedField savedCalculatedField = createSimpleCalculatedField(deviceProfileId);
@ -283,46 +324,61 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testEntityAddedAndDeleted() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
waitInitialTelemetry(device.getId(), "temperature");
CalculatedField savedCalculatedField = createSimpleCalculatedField(deviceProfileId);
String newDeviceToken = "mmmXRIVRsq9lbnTP2XBE";
Device newDevice = testRestClient.postDevice(newDeviceToken, createDevice("Device 2", deviceProfileId));
await().alias("create CF -> perform initial calculation for device by profile").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0");
});
String deviceToken2 = "beeUmKVqsq3lvnovt6BE";
Device device2 = testRestClient.postDevice(deviceToken2, createDevice("Device 2", deviceProfileId));
await().alias("create device by profile -> perform initial calculation for new device by profile").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
// used default value since telemetry is not present
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(newDevice.getId());
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device2.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6");
});
DeviceProfile newDeviceProfile = testRestClient.postDeviceProfile(defaultDeviceProfile("Test Profile"));
newDevice.setDeviceProfileId(newDeviceProfile.getId());
testRestClient.postDevice(newDeviceToken, newDevice);
device2.setDeviceProfileId(newDeviceProfile.getId());
testRestClient.postDevice(deviceToken2, device2);
testRestClient.postTelemetry(newDeviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
testRestClient.postTelemetry(deviceToken2, JacksonUtil.toJsonNode("{\"temperature\":25}"));
await().alias("update telemetry -> no updates").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(newDevice.getId());
JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device2.getId());
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull();
assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6");
});
testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId());
testRestClient.deleteDeviceIfExists(device2.getId());
testRestClient.deleteDeviceProfileIfExists(newDeviceProfile);
}
@Test
public void testEntityIdIsProfileAndRefEntityIsCommon() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.32}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.86}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":73.58}"));
waitInitialTelemetry(device.getId(), "temperatureInF");
testRestClient.postTelemetryAttribute(asset.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}"));
CalculatedField savedCalculatedField = createScriptCalculatedField(deviceProfileId, asset.getId());
@ -351,9 +407,6 @@ public class CalculatedFieldTest extends AbstractContainerTest {
@Test
public void testPerformSerialsOfCalculationsForGeofencingType() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// Device and initial coords (inside Allowed, outside Restricted)
String deviceToken = "geoDeviceTokenA";
Device device = testRestClient.postDevice(deviceToken, createDevice("GF Device", deviceProfileId));
@ -433,13 +486,13 @@ public class CalculatedFieldTest extends AbstractContainerTest {
});
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
testRestClient.deleteDeviceIfExists(device.getId());
testRestClient.deleteAsset(allowed.getId());
testRestClient.deleteAsset(restricted.getId());
}
@Test
public void testPropagationCalculatedField_withExpression() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// --- Arrange entities ---
String deviceToken = "propagationDeviceTokenA";
Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device With Expression", deviceProfileId));
@ -516,13 +569,13 @@ public class CalculatedFieldTest extends AbstractContainerTest {
});
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
testRestClient.deleteDeviceIfExists(device.getId());
testRestClient.deleteAsset(asset1.getId());
testRestClient.deleteAsset(asset2.getId());
}
@Test
public void testPropagationCalculatedField_withoutExpression() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// --- Arrange entities ---
String deviceToken = "propagationDeviceTokenB";
Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device Without Expression", deviceProfileId));
@ -601,13 +654,13 @@ public class CalculatedFieldTest extends AbstractContainerTest {
});
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
testRestClient.deleteDeviceIfExists(device.getId());
testRestClient.deleteAsset(asset1.getId());
testRestClient.deleteAsset(asset2.getId());
}
@Test
public void testRelatedEntitiesAggregationCalculatedField() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// --- Create entities ---
String device_1_1_token = "000000011";
Device device_1_1 = testRestClient.postDevice(device_1_1_token, createDevice("Device 1-1", deviceProfileId));
@ -731,6 +784,11 @@ public class CalculatedFieldTest extends AbstractContainerTest {
});
testRestClient.deleteCalculatedFieldIfExists(calculatedField.getId());
testRestClient.deleteDeviceIfExists(device_1_1.getId());
testRestClient.deleteDeviceIfExists(device_1_2.getId());
testRestClient.deleteDeviceIfExists(device_2_1.getId());
testRestClient.deleteDeviceIfExists(device_2_2.getId());
testRestClient.deleteAsset(asset2.getId());
}
private CalculatedField createOccupancyCF(EntityId entityId) {
@ -783,10 +841,6 @@ public class CalculatedFieldTest extends AbstractContainerTest {
return testRestClient.postCalculatedField(calculatedField);
}
private CalculatedField createSimpleCalculatedField() {
return createSimpleCalculatedField(device.getId());
}
private CalculatedField createSimpleCalculatedField(EntityId entityId) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(entityId);
@ -799,7 +853,7 @@ public class CalculatedFieldTest extends AbstractContainerTest {
Argument argument = new Argument();
ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null);
argument.setRefEntityKey(refEntityKey);
argument.setDefaultValue("12"); // not used because real telemetry value in db is present
argument.setDefaultValue("12");
config.setArguments(Map.of("T", argument));
config.setExpression("(T * 9/5) + 32");
@ -844,6 +898,16 @@ public class CalculatedFieldTest extends AbstractContainerTest {
return testRestClient.postCalculatedField(calculatedField);
}
private void waitInitialTelemetry(EntityId entityId, String key) {
await().alias("wait initial telemetry").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode telemetry = testRestClient.getLatestTelemetry(entityId);
assertThat(telemetry).isNotNull();
assertThat(telemetry.get(key)).isNotNull();
});
}
private Device createDevice(String name, DeviceProfileId deviceProfileId) {
Device device = new Device();
device.setName(name);

2
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/base/AbstractDriverBaseTest.java

@ -303,7 +303,7 @@ abstract public class AbstractDriverBaseTest extends AbstractContainerTest {
public void deleteDeviceProfileByTitle(String deviceProfileTitle) {
DeviceProfile deviceProfile = getDeviceProfileByName(deviceProfileTitle);
if (deviceProfile != null) {
testRestClient.deleteDeviseProfile(deviceProfile.getId());
testRestClient.deleteDeviceProfile(deviceProfile.getId());
}
}

2
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/CreateDeviceProfileImportTest.java

@ -52,7 +52,7 @@ public class CreateDeviceProfileImportTest extends AbstractDriverBaseTest {
@AfterMethod
public void delete() {
if (name != null) {
testRestClient.deleteDeviseProfile(getDeviceProfileByName(name).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(name).getId());
name = null;
}
}

2
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/CreateDeviceProfileTest.java

@ -51,7 +51,7 @@ public class CreateDeviceProfileTest extends AbstractDriverBaseTest {
@AfterMethod
public void delete() {
if (name != null) {
testRestClient.deleteDeviseProfile(getDeviceProfileByName(name).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(name).getId());
name = null;
}
}

2
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/DeviceProfileEditMenuTest.java

@ -51,7 +51,7 @@ public class DeviceProfileEditMenuTest extends AbstractDriverBaseTest {
@AfterMethod
public void delete() {
if (name != null) {
testRestClient.deleteDeviseProfile(getDeviceProfileByName(name).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(name).getId());
name = null;
}
}

2
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/SearchDeviceProfileTest.java

@ -46,7 +46,7 @@ public class SearchDeviceProfileTest extends AbstractDriverBaseTest {
@AfterMethod
public void delete() {
if (name != null) {
testRestClient.deleteDeviseProfile(getDeviceProfileByName(name).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(name).getId());
name = null;
}
}

14
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/deviceProfileSmoke/SortByNameTest.java

@ -45,7 +45,7 @@ public class SortByNameTest extends AbstractDriverBaseTest {
@AfterMethod
public void delete() {
if (name != null) {
testRestClient.deleteDeviseProfile(getDeviceProfileByName(name).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(name).getId());
name = null;
}
}
@ -83,9 +83,9 @@ public class SortByNameTest extends AbstractDriverBaseTest {
profilesPage.setProfileName(2);
String thirdDeviceProfile = profilesPage.getProfileName();
testRestClient.deleteDeviseProfile(getDeviceProfileByName(deviceProfile).getId());
testRestClient.deleteDeviseProfile(getDeviceProfileByName(deviceProfileNumber).getId());
testRestClient.deleteDeviseProfile(getDeviceProfileByName(deviceProfileSymbol).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(deviceProfile).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(deviceProfileNumber).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(deviceProfileSymbol).getId());
Assert.assertEquals(firstDeviceProfile, deviceProfileSymbol);
Assert.assertEquals(secondDeviceProfile, deviceProfileNumber);
@ -126,9 +126,9 @@ public class SortByNameTest extends AbstractDriverBaseTest {
profilesPage.setProfileName(lastIndex - 2);
String thirdDeviceProfile = profilesPage.getProfileName();
testRestClient.deleteDeviseProfile(getDeviceProfileByName(deviceProfile).getId());
testRestClient.deleteDeviseProfile(getDeviceProfileByName(deviceProfileNumber).getId());
testRestClient.deleteDeviseProfile(getDeviceProfileByName(deviceProfileSymbol).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(deviceProfile).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(deviceProfileNumber).getId());
testRestClient.deleteDeviceProfile(getDeviceProfileByName(deviceProfileSymbol).getId());
Assert.assertEquals(firstDeviceProfile, deviceProfileSymbol);
Assert.assertEquals(secondDeviceProfile, deviceProfileNumber);

6
msa/js-executor/yarn.lock

@ -1253,9 +1253,9 @@ pump@^3.0.0:
once "^1.3.1"
qs@^6.14.0:
version "6.14.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
version "6.14.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159"
integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
dependencies:
side-channel "^1.1.0"

88
msa/pom.xml

@ -32,7 +32,7 @@
<properties>
<main.dir>${basedir}/..</main.dir>
<docker.repo>thingsboard</docker.repo>
<docker.base.image>thingsboard/openjdk17:bookworm-slim</docker.base.image>
<docker.base.image>thingsboard/openjdk25:trixie-slim</docker.base.image>
<dockerfile.skip>true</dockerfile.skip>
<blackBoxTests.skip>true</blackBoxTests.skip>
<dockerfile-maven.version>1.4.13</dockerfile-maven.version>
@ -125,6 +125,92 @@
</plugins>
</build>
</profile>
<profile>
<id>push-lts-docker-amd-arm-images</id>
<activation>
<property>
<name>push-lts-docker-amd-arm-images</name>
</property>
</activation>
<properties>
<docker.lts.tag>4.3.0-latest</docker.lts.tag>
<docker.skip.latest.tag>false</docker.skip.latest.tag>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<executions>
<execution>
<id>push-latest-docker-amd-arm-images</id>
<phase>${docker.push-arm-amd-image.phase}</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<skip>${docker.skip.latest.tag}</skip>
<executable>docker</executable>
<workingDirectory>${project.build.directory}</workingDirectory>
<arguments>
<argument>buildx</argument>
<argument>build</argument>
<argument>-t</argument>
<argument>${docker.repo}/${docker.name}:latest</argument>
<argument>--platform=linux/amd64,linux/arm64</argument>
<argument>-o</argument>
<argument>type=registry</argument>
<argument>.</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>push-version-docker-amd-arm-images</id>
<phase>${docker.push-arm-amd-image.phase}</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>docker</executable>
<workingDirectory>${project.build.directory}</workingDirectory>
<arguments>
<argument>buildx</argument>
<argument>build</argument>
<argument>-t</argument>
<argument>${docker.repo}/${docker.name}:${project.version}</argument>
<argument>--platform=linux/amd64,linux/arm64</argument>
<argument>-o</argument>
<argument>type=registry</argument>
<argument>.</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>push-lts-docker-amd-arm-images</id>
<phase>${docker.push-arm-amd-image.phase}</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>docker</executable>
<workingDirectory>${project.build.directory}</workingDirectory>
<arguments>
<argument>buildx</argument>
<argument>build</argument>
<argument>-t</argument>
<argument>${docker.repo}/${docker.name}:${docker.lts.tag}</argument>
<argument>--platform=linux/amd64,linux/arm64</argument>
<argument>-o</argument>
<argument>type=registry</argument>
<argument>.</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<build>
<extensions>

15
msa/tb-node/docker/Dockerfile

@ -16,6 +16,21 @@
FROM ${docker.base.image}
# For SVG renderer Debian Trixie slim fonts should be installed explicitly
RUN echo "Previously installed font libraries:" \
&& (dpkg -l | grep -E 'font|dejavu|harfbuzz' || echo " (none)") \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
libharfbuzz0b fontconfig fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* \
&& echo "Current fonts libraries:" \
&& (dpkg -l | grep -E 'font|dejavu|harfbuzz') \
&& echo "Font config list:" \
&& fc-list && test -n "$(fc-list)" \
&& (fc-list | grep -i dejavu) \
&& echo "Harfbuzz library test with ldconfig:" \
&& (ldconfig -p | grep harfbuzz)
COPY logback.xml start-tb-node.sh ${pkg.name}.deb /tmp/
RUN chmod a+x /tmp/*.sh \

6
msa/web-ui/yarn.lock

@ -1330,9 +1330,9 @@ pump@^3.0.0:
once "^1.3.1"
qs@^6.14.0:
version "6.14.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
version "6.14.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159"
integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
dependencies:
side-channel "^1.1.0"

60
ui-ngx/patches/@mat-datetimepicker+core+14.0.0.patch

@ -15,8 +15,39 @@ index 7ecfae7..08363d3 100644
date = this._adapter.createDatetime(this._adapter.getYear(this.activeDate), this._adapter.getMonth(this.activeDate), this._adapter.getDate(this.activeDate), this._adapter.getHour(this.activeDate), value);
}
this._timeChanged = true;
diff --git a/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs b/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs
index 5b82377..30e35ce 100644
--- a/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs
+++ b/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs
@@ -48,7 +48,7 @@ export class MatDatetimepickerContentComponent {
}
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
- /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: i2.MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
+ /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: i2.MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, decorators: [{
type: Component,
@@ -56,7 +56,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImpor
class: 'mat-datetimepicker-content',
'[class.mat-datetimepicker-content-touch]': 'datetimepicker?.touchUi',
'(keydown)': '_handleKeydown($event)',
- }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }]
+ }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }]
}], propDecorators: { _calendar: [{
type: ViewChild,
args: [MatDatetimepickerCalendarComponent, { static: true }]
@@ -301,7 +301,7 @@ export class MatDatetimepickerComponent {
const overlayConfig = new OverlayConfig({
positionStrategy: this._createPopupPositionStrategy(),
hasBackdrop: true,
- backdropClass: 'mat-overlay-transparent-backdrop',
+ backdropClass: 'cdk-overlay-transparent-backdrop',
direction: this._dir ? this._dir.value : 'ltr',
scrollStrategy: this._scrollStrategy(),
panelClass: 'mat-datetimepicker-popup',
diff --git a/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs b/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs
index 00f4a52..df688e3 100644
index 00f4a52..99c0ac1 100644
--- a/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs
+++ b/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs
@@ -946,9 +946,9 @@ class MatDatetimepickerClockComponent {
@ -32,3 +63,30 @@ index 00f4a52..df688e3 100644
date = this._adapter.createDatetime(this._adapter.getYear(this.activeDate), this._adapter.getMonth(this.activeDate), this._adapter.getDate(this.activeDate), this._adapter.getHour(this.activeDate), value);
}
this._timeChanged = true;
@@ -1911,7 +1911,7 @@ class MatDatetimepickerContentComponent {
}
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
- /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1$2.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
+ /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1$2.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, decorators: [{
type: Component,
@@ -1919,7 +1919,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImpor
class: 'mat-datetimepicker-content',
'[class.mat-datetimepicker-content-touch]': 'datetimepicker?.touchUi',
'(keydown)': '_handleKeydown($event)',
- }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }]
+ }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "<mat-datetimepicker-calendar\n (_userSelection)=\"datetimepicker.close()\"\n (selectedChange)=\"onSelectionChange($event)\"\n (viewChanged)=\"datetimepicker._viewChanged($event)\"\n [ariaNextMonthLabel]=\"datetimepicker.ariaNextMonthLabel\"\n [ariaNextYearLabel]=\"datetimepicker.ariaNextYearLabel\"\n [ariaPrevMonthLabel]=\"datetimepicker.ariaPrevMonthLabel\"\n [ariaPrevYearLabel]=\"datetimepicker.ariaPrevYearLabel\"\n [preventSameDateTimeSelection]=\"datetimepicker.preventSameDateTimeSelection\"\n [attr.mode]=\"datetimepicker.mode\"\n [dateFilter]=\"datetimepicker._dateFilter\"\n [id]=\"datetimepicker.id\"\n [maxDate]=\"datetimepicker._maxDate\"\n [minDate]=\"datetimepicker._minDate\"\n [multiYearSelector]=\"datetimepicker.multiYearSelector\"\n [selected]=\"datetimepicker._selected\"\n [startAt]=\"datetimepicker.startAt\"\n [startView]=\"datetimepicker.startView\"\n [timeInterval]=\"datetimepicker.timeInterval\"\n [twelvehour]=\"datetimepicker.twelvehour\"\n [type]=\"datetimepicker.type\"\n cdkTrapFocus\n class=\"mat-typography\"\n>\n</mat-datetimepicker-calendar>\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }]
}], propDecorators: { _calendar: [{
type: ViewChild,
args: [MatDatetimepickerCalendarComponent, { static: true }]
@@ -2164,7 +2164,7 @@ class MatDatetimepickerComponent {
const overlayConfig = new OverlayConfig({
positionStrategy: this._createPopupPositionStrategy(),
hasBackdrop: true,
- backdropClass: 'mat-overlay-transparent-backdrop',
+ backdropClass: 'cdk-overlay-transparent-backdrop',
direction: this._dir ? this._dir.value : 'ltr',
scrollStrategy: this._scrollStrategy(),
panelClass: 'mat-datetimepicker-popup',

112
ui-ngx/src/app/core/services/calculated-field-form.service.ts

@ -0,0 +1,112 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { DestroyRef, inject, Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { pairwise, switchMap } from 'rxjs/operators';
import { Observable } from 'rxjs';
import {
CalculatedField,
CalculatedFieldConfiguration,
CalculatedFieldEventArguments,
CalculatedFieldType,
OutputStrategyType
} from '@shared/models/calculated-field.models';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { isDefined } from '@core/utils';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { CalculatedFieldsTableEntity } from '@home/components/calculated-fields/calculated-fields-table-config';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })
export class CalculatedFieldFormService {
private fb = inject(FormBuilder);
private calculatedFieldsService = inject(CalculatedFieldsService);
buildForm(): FormGroup {
return this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
entityId: [null, Validators.required],
type: [CalculatedFieldType.SIMPLE],
debugSettings: [],
configuration: this.fb.control<CalculatedFieldConfiguration>({} as CalculatedFieldConfiguration),
});
}
buildAlarmRuleForm(): FormGroup {
return this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
entityId: [null, Validators.required],
type: [CalculatedFieldType.ALARM],
debugSettings: [],
configuration: this.fb.group({
type: [CalculatedFieldType.ALARM],
arguments: this.fb.control({}, Validators.required),
propagate: [false],
propagateToOwner: [false],
propagateToTenant: [false],
propagateRelationTypes: [null],
createRules: [null, Validators.required],
clearRule: [null],
}),
});
}
setupTypeChange(form: FormGroup, destroyRef: DestroyRef, isEditActive?: () => boolean): void {
form.get('type').valueChanges.pipe(
pairwise(),
takeUntilDestroyed(destroyRef)
).subscribe(([prevType, nextType]) => {
const shouldCheck = isEditActive ? isEditActive() : true;
if (shouldCheck) {
if (![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(prevType) ||
![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(nextType)) {
form.get('configuration').setValue({} as CalculatedFieldConfiguration, { emitEvent: false });
}
}
});
}
prepareConfig(configuration: CalculatedFieldConfiguration): CalculatedFieldConfiguration {
const config = configuration || ({} as CalculatedFieldConfiguration);
if (config.type !== CalculatedFieldType.ALARM) {
if (isDefined(config?.output) && !config?.output?.strategy) {
config.output.strategy = { type: OutputStrategyType.RULE_CHAIN };
}
}
return config;
}
testScript(
calculatedFieldId: string,
formValue: CalculatedField,
testDialogFn: (calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit?: boolean, expression?: string) => Observable<string>,
destroyRef: DestroyRef,
expression?: string,
): Observable<string> {
if (calculatedFieldId) {
return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true})
.pipe(
switchMap(event => {
const args = event?.arguments ? JSON.parse(event.arguments) : null;
return testDialogFn(formValue, args, false, expression);
}),
takeUntilDestroyed(destroyRef)
);
}
return testDialogFn(formValue, null, false, expression);
}
}

4
ui-ngx/src/app/core/services/utils.service.ts

@ -454,11 +454,11 @@ export class UtilsService {
return base64toObj(b64Encoded);
}
public applyCssToElement(renderer: Renderer2, element: any, cssClassPrefix: string, css: string): string {
public applyCssToElement(renderer: Renderer2, element: any, cssClassPrefix: string, css: string, addTbDefaultClass: boolean = false): string {
const cssParser = new cssjs();
cssParser.testMode = false;
const cssClass = `${cssClassPrefix}-${guid()}`;
cssParser.cssPreviewNamespace = cssClass;
cssParser.cssPreviewNamespace = addTbDefaultClass ? 'tb-default .' + cssClass : cssClass;
cssParser.createStyleElement(cssClass, css);
renderer.addClass(element, cssClass);
return cssClass;

2
ui-ngx/src/app/modules/common/modules-map.ts

@ -121,6 +121,7 @@ import * as TbErrorComponent from '@shared/components/tb-error.component';
import * as TbCheatSheetComponent from '@shared/components/cheatsheet.component';
import * as BreadcrumbComponent from '@shared/components/breadcrumb.component';
import * as UserMenuComponent from '@shared/components/user-menu.component';
import * as TimeUnitInputComponent from '@shared/components/time-unit-input.component';
import * as TimewindowComponent from '@shared/components/time/timewindow.component';
import * as TimewindowPanelComponent from '@shared/components/time/timewindow-panel.component';
import * as TimeintervalComponent from '@shared/components/time/timeinterval.component';
@ -458,6 +459,7 @@ class ModulesMap implements IModulesMap {
'@shared/components/cheatsheet.component': TbCheatSheetComponent,
'@shared/components/breadcrumb.component': BreadcrumbComponent,
'@shared/components/user-menu.component': UserMenuComponent,
'@shared/components/time-unit-input.component': TimeUnitInputComponent,
'@shared/components/time/timewindow.component': TimewindowComponent,
'@shared/components/time/timewindow-panel.component': TimewindowPanelComponent,
'@shared/components/time/timeinterval.component': TimeintervalComponent,

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

@ -58,31 +58,16 @@
/>
</div>
@if (!data.entityId) {
<div class="flex flex-row gap-4 xs:flex-col" formGroupName="entityId">
<tb-entity-type-select #entityTypeSelect
appearance="outline"
subscriptSizing="dynamic"
class="flex-1"
showLabel
required
label="{{ 'entity.entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="alarmRuleEntityTypeList"
formControlName="entityType">
</tb-entity-type-select>
@if (fieldFormGroup.get('entityId.entityType').value) {
<tb-entity-autocomplete #entityAutocompleteComponent
formControlName="id"
appearance="outline"
subscriptSizing="dynamic"
class="flex-1"
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="fieldFormGroup.get('entityId.entityType').value"
(entityChanged)="changeEntity($event)"
/>
}
</div>
<tb-entity-select formControlName="entityId"
#entitySelect
appearance="outline"
required
entityTypeLabel="{{ 'entity.entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="alarmRuleEntityTypeList"
[defaultEntityType]="EntityType.DEVICE_PROFILE"
(entityChanged)="changeEntity($event)">
</tb-entity-select>
}
</div>
<ng-container formGroupName="configuration">
@ -91,7 +76,7 @@
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="data.entityId || fieldFormGroup.get('entityId').value"
[tenantId]="data.tenantId"
[ownerId]="data.ownerId"
[ownerId]="ownerId"
[watchKeyChange]="true"
[entityName]="entityName"/>
</div>
@ -150,6 +135,7 @@
placeholder="{{ 'alarm-rule.alarm-rule-relation-types-list' | translate }}"
hint="{{ 'alarm-rule.alarm-rule-relation-types-list-hint' | translate }}"
[predefinedValues]="predefinedTypeValues"
allowUserValue
formControlName="propagateRelationTypes">
</tb-string-items-list>
}

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

@ -18,12 +18,11 @@ import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@an
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { AliasEntityType, EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
@ -39,13 +38,15 @@ import {
} from "@shared/models/alarm-rule.models";
import { deepTrim } from "@core/utils";
import { combineLatest, Observable } from "rxjs";
import { debounceTime, startWith, 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 { debounceTime, startWith } from "rxjs/operators";
import { RelationTypes } from "@shared/models/relation.models";
import { StringItemsOption } from "@shared/components/string-items-list.component";
import { BaseData } from "@shared/models/base-data";
import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service';
import { EntitySelectComponent } from '@shared/components/entity/entity-select.component';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { AssetInfo } from '@shared/models/asset.models';
import { DeviceInfo } from '@shared/models/device.models';
export interface AlarmRuleDialogData {
value?: CalculatedField;
@ -67,24 +68,9 @@ export interface AlarmRuleDialogData {
})
export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogComponent, CalculatedField> {
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.ALARM],
debugSettings: [],
entityId: this.fb.group({
entityType: this.fb.control<EntityType | AliasEntityType | null>(EntityType.DEVICE_PROFILE, Validators.required),
id: [null as null | string, Validators.required],
}),
configuration: this.fb.group({
arguments: this.fb.control({}, Validators.required),
propagate: [false],
propagateToOwner: [false],
propagateToTenant: [false],
propagateRelationTypes: [null],
createRules: [null, Validators.required],
clearRule: [null],
}),
});
@ViewChild('entitySelect') entitySelect!: EntitySelectComponent;
fieldFormGroup: FormGroup ;
additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig,
@ -100,23 +86,21 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
separatorKeysCodes = [ENTER, COMMA, SEMICOLON];
entityName = this.data.entityName;
ownerId = this.data.ownerId;
disabledClearRuleButton = false;
disabledArguments = false;
isLoading = false;
@ViewChild('entityTypeSelect') entityTypeSelect: EntityTypeSelectComponent;
@ViewChild('entityAutocompleteComponent') entityAutocompleteComponent: EntityAutocompleteComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData,
protected dialogRef: MatDialogRef<AlarmRuleDialogComponent, CalculatedField>,
private calculatedFieldsService: CalculatedFieldsService,
private entityService: EntityService,
private destroyRef: DestroyRef,
private fb: FormBuilder) {
private cfFormService: CalculatedFieldFormService) {
super(store, router, dialogRef);
this.fieldFormGroup = this.cfFormService.buildAlarmRuleForm();
this.applyDialogData();
this.updateRulesValidators();
@ -132,7 +116,7 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
if (!this.data.entityId) {
combineLatest([
this.fieldFormGroup.get('entityId.id')!.valueChanges.pipe(startWith(this.fieldFormGroup.get('entityId.id')!.value)),
this.fieldFormGroup.get('entityId')!.valueChanges.pipe(startWith(this.fieldFormGroup.get('entityId')!.value)),
this.fieldFormGroup.get('name')!.valueChanges.pipe(startWith(this.fieldFormGroup.get('name')!.value))
]).pipe(
debounceTime(50),
@ -196,8 +180,7 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
});
} else {
this.fieldFormGroup.get('name').markAsTouched();
this.entityTypeSelect?.markAsTouched();
this.entityAutocompleteComponent?.markAsTouched();
this.entitySelect.entityAutocompleteMarkAsTouched();
}
}
@ -207,18 +190,13 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
}
onTestScript(expression: string): Observable<string> {
const calculatedFieldId = this.data.value?.id?.id;
if (calculatedFieldId) {
return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true})
.pipe(
switchMap(event => {
const args = event?.arguments ? JSON.parse(event.arguments) : null;
return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, args, false);
}),
takeUntilDestroyed(this.destroyRef)
)
}
return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, null, false);
return this.cfFormService.testScript(
this.data.value?.id?.id,
this.fromGroupValue,
this.data.getTestScriptDialogFn,
this.destroyRef,
expression
);
}
private updateRulesValidators(): void {
@ -249,6 +227,12 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
changeEntity(entity: BaseData<EntityId>): void {
this.entityName = entity.name;
if (this.isAssignedToCustomer(entity as AssetInfo | DeviceInfo)) {
this.ownerId = (entity as AssetInfo | DeviceInfo).customerId;
}
}
private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean {
return entity && entity.customerId && entity.customerId.id !== NULL_UUID;
}
}

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

@ -53,6 +53,7 @@ import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alar
import {
AlarmRuleFilterPredicateNoDataValueComponent
} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component";
import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component';
@NgModule({
declarations: [
@ -73,7 +74,8 @@ import {
AlarmRuleDetailsDialogComponent,
AlarmRuleFilterConfigComponent,
AlarmRuleTableHeaderComponent,
AlarmRuleFilterPredicateNoDataValueComponent
AlarmRuleFilterPredicateNoDataValueComponent,
AlarmRulesComponent
],
imports: [
CommonModule,
@ -83,6 +85,7 @@ import {
],
exports: [
AlarmRuleDialogComponent,
AlarmRulesComponent
]
})
export class AlarmRuleModule { }

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

@ -21,7 +21,7 @@ import {
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import { Direction } from '@shared/models/page/sort-order';
import { MatDialog } from '@angular/material/dialog';
@ -36,7 +36,7 @@ import { DestroyRef, Renderer2 } from '@angular/core';
import { EntityDebugSettings } from '@shared/models/entity.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { catchError, filter, switchMap, tap } from 'rxjs/operators';
import { catchError, filter, first, switchMap, tap } from 'rxjs/operators';
import {
ArgumentEntityType,
ArgumentType,
@ -67,6 +67,10 @@ import {
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestScriptDialogData
} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component";
import { AlarmRulesTabsComponent } from '@home/pages/alarm/alarm-rules-tabs.component';
import { Router } from '@angular/router';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component';
type AlarmRuleTableEntity = CalculatedField | CalculatedFieldInfo;
@ -93,27 +97,26 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService,
private utilsService: UtilsService,
private router: Router,
public pageMode: boolean = false,
) {
super();
if (this.pageMode) {
this.headerComponent = AlarmRuleTableHeaderComponent;
this.entityComponent = AlarmRulesComponent;
this.entityTabsComponent = AlarmRulesTabsComponent;
this.rowPointer = true;
this.handleRowClick = ($event, model) => {
this.editCalculatedField($event, model);
return true;
};
}
this.tableTitle = this.pageMode ? '' : this.translate.instant('alarm-rule.alarm-rules');
this.detailsPanelEnabled = false;
this.detailsPanelEnabled = this.pageMode;
this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD);
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = {
type: 'alarm-rule.alarm-rule',
typePlural: 'alarm-rule.alarm-rules',
list: 'alarm-rule.list',
add: 'action.add',
details: 'alarm-rule.details',
noEntities: 'alarm-rule.no-found',
search: 'action.search',
selectedEntities: 'alarm-rule.selected-fields'
@ -121,11 +124,17 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink);
this.addEntity = this.getCalculatedAlarmDialog.bind(this);
this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id);
this.saveEntity = (alarmRule) => this.calculatedFieldsService.saveCalculatedField(alarmRule);
this.deleteEntityTitle = (field) => this.translate.instant('alarm-rule.delete-title', {title: field.name});
this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text');
this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count});
this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text');
this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id);
this.onEntityAction = action => this.onCFAction(action);
this.addActionDescriptors = [
{
name: this.translate.instant('alarm-rule.create'),
@ -177,7 +186,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
name: this.translate.instant('entity-view.events'),
icon: 'mdi:clipboard-text-clock',
isEnabled: () => true,
onAction: ($event, entity) => this.openDebugEventsDialog($event, entity),
onAction: ($event, entity) =>
this.pageMode ? this.openDebugTab($event, entity) : this.openDebugEventsDialog($event, entity),
},
{
name: '',
@ -186,14 +196,18 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
isEnabled: () => true,
iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline',
onAction: ($event, entity) => this.onOpenDebugConfig($event, entity),
},
{
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: ($event, entity) => this.editCalculatedField($event, entity),
}
);
if (!this.pageMode) {
this.cellActionDescriptors.push(
{
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: ($event, entity) => this.editCalculatedField($event, entity),
}
)
}
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<AlarmRuleTableEntity>> {
@ -288,13 +302,29 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
debugEventTypes:[DebugEventType.DEBUG_CALCULATED_FIELD],
disabledEventTypes:[EventType.LC_EVENT, EventType.ERROR, EventType.STATS],
defaultEventType: DebugEventType.DEBUG_CALCULATED_FIELD,
debugActionEnabledFn: () => false
debugActionDisabled: true
}
})
.afterClosed()
.subscribe();
}
private openDebugTab($event: Event, calculatedField: AlarmRuleTableEntity) {
const table = this.getTable();
if (!table.isDetailsOpen) {
table.toggleEntityDetails($event, calculatedField);
if (table.entityDetailsPanel.matTabGroup._tabs.length > 1) {
table.entityDetailsPanel.matTabGroup.selectedIndex = 1;
} else {
table.entityDetailsPanel.matTabGroup._tabs.changes.pipe(
first()
).subscribe(() => {
table.entityDetailsPanel.matTabGroup.selectedIndex = 1;
})
}
}
}
private exportAlarmRule($event: Event, calculatedField: AlarmRuleTableEntity): void {
$event?.stopPropagation();
this.importExportService.exportCalculatedField(calculatedField.id.id);
@ -348,7 +378,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
).subscribe(() => this.updateData());
}
private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, expression: string, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable<string> {
private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable<string> {
if (calculatedField.type === CalculatedFieldType.ALARM) {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const type = calculatedField.configuration.arguments[key].refEntityKey.type;
@ -384,4 +414,22 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
return of(null);
}
}
private openCalculatedField($event: Event, entity: AlarmRuleTableEntity) {
$event?.stopPropagation();
const url = this.router.createUrlTree(['alarms', 'alarm-rules', entity.id.id]);
this.router.navigateByUrl(url);
}
private onCFAction(action: EntityAction<AlarmRuleTableEntity>): boolean {
switch (action.action) {
case 'open':
this.openCalculatedField(action.event, action.entity);
return true;
case 'export':
this.exportAlarmRule(action.event, action.entity);
return true;
}
return false;
}
}

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

@ -36,7 +36,7 @@ import { EntityDebugSettingsService } from '@home/components/entity/debug/entity
import { DatePipe } from '@angular/common';
import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config";
import { UtilsService } from "@core/services/utils.service";
import { ActivatedRoute } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
@Component({
selector: 'tb-alarm-rules-table',
@ -70,6 +70,7 @@ export class AlarmRulesTableComponent {
private utilsService: UtilsService,
private destroyRef: DestroyRef,
private route: ActivatedRoute,
private router: Router
) {
this.pageMode = !!this.route.snapshot.data.isPage;
effect(() => {
@ -88,6 +89,7 @@ export class AlarmRulesTableComponent {
this.importExportService,
this.entityDebugSettingsService,
this.utilsService,
this.router,
this.pageMode,
);
this.cd.markForCheck();

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

@ -0,0 +1,160 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{ 'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'export')"
[class.!hidden]="isEdit">
{{ 'action.export' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{ 'action.delete' | translate }}
</button>
</div>
<div class="mat-padding flex flex-1 flex-col" [formGroup]="entityForm">
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title mb-4">{{ 'common.general' | translate }}</div>
<div class="flex items-center gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'entity-field.title' | translate }}</mat-label>
<input matInput maxlength="255" formControlName="name" required>
@if (entityForm.get('name').errors && entityForm.get('name').touched) {
<mat-error>
@if (entityForm.get('name').hasError('required')) {
{{ 'common.hint.title-required' | translate }}
} @else if (entityForm.get('name').hasError('pattern')) {
{{ 'common.hint.title-pattern' | translate }}
} @else if (entityForm.get('name').hasError('maxlength')) {
{{ 'common.hint.title-max-length' | translate }}
}
</mat-error>
}
<mat-hint></mat-hint>
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
style="margin-bottom: 22px"
[entityType]="EntityType.CALCULATED_FIELD"
[additionalActionConfig]="additionalDebugActionConfig"
/>
</div>
<tb-entity-select formControlName="entityId"
appearance="outline"
required
entityTypeLabel="{{ 'entity.entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="alarmRuleEntityTypeList"
(entityChanged)="changeEntity($event)">
</tb-entity-select>
</div>
<ng-container formGroupName="configuration">
<div class="tb-form-panel" [class.disabled]="!isEditValue">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[ownerId]="ownerId"
[watchKeyChange]="true"
[entityName]="entityName"/>
</div>
<div class="tb-form-panel" [class.disabled]="!isEditValue">
<div class="tb-form-panel-title">{{ 'alarm-rule.create-conditions' | translate }}</div>
<div class="flex flex-1 flex-col">
<tb-create-cf-alarm-rules formControlName="createRules" [arguments]="arguments" [testScript]="onTestScript.bind(this)">
</tb-create-cf-alarm-rules>
</div>
</div>
<div class="tb-form-panel" [class.disabled]="!isEditValue">
<div class="tb-form-panel-title">{{ 'alarm-rule.clear-condition' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-2 pb-2"
[class.!hidden]="!configFormGroup.get('clearRule').value">
<div class="clear-alarm-rule flex flex-1 flex-row">
<tb-cf-alarm-rule formControlName="clearRule" class="flex-1" [arguments]="arguments" [testScript]="onTestScript.bind(this)" isClearCondition>
</tb-cf-alarm-rule>
</div>
<button mat-icon-button
class="button-icon"
type="button"
(click)="removeClearAlarmRule()"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
<div *ngIf="!configFormGroup.get('clearRule').value">
<span translate class="tb-prompt flex items-center justify-center text-base">alarm-rule.no-clear-alarm-rule</span>
</div>
<div [class.!hidden]="configFormGroup.get('clearRule').value">
<button mat-stroked-button color="primary"
[disabled]="!isEditValue"
type="button"
(click)="addClearAlarmRule()">
{{ 'alarm-rule.add-clear-alarm-rule' | translate }}
</button>
</div>
</div>
<div class="tb-form-panel no-gap">
<mat-expansion-panel class="tb-settings" [expanded]="false">
<mat-expansion-panel-header [class.tb-disabled]="!isEditValue">
{{ 'alarm-rule.advanced-settings' | translate }}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-panel stroked no-padding no-gap">
<div class="tb-form-row no-border">
<mat-slide-toggle class="mat-slide margin" formControlName="propagate">
{{ 'alarm-rule.propagate-alarm' | translate }}
</mat-slide-toggle>
</div>
@if (configFormGroup.get('propagate').value) {
<tb-string-items-list appearance="outline"
class="flex flex-1 px-4"
label="{{ 'alarm-rule.alarm-rule-relation-types-list' | translate }}"
placeholder="{{ 'alarm-rule.alarm-rule-relation-types-list' | translate }}"
hint="{{ 'alarm-rule.alarm-rule-relation-types-list-hint' | translate }}"
[predefinedValues]="predefinedTypeValues"
allowUserValue
formControlName="propagateRelationTypes">
</tb-string-items-list>
}
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide margin" formControlName="propagateToOwner">
{{ 'alarm-rule.propagate-alarm-to-owner' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide margin" formControlName="propagateToTenant">
{{ 'alarm-rule.propagate-alarm-to-tenant' | translate }}
</mat-slide-toggle>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</ng-container>
</div>
</div>

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

@ -0,0 +1,199 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, DestroyRef, inject, Inject, Input } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { EntityComponent } from '@home/components/entity/entity.component';
import { FormBuilder, FormGroup } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import {
CalculatedFieldArgument,
CalculatedFieldConfiguration,
CalculatedFieldInfo,
CalculatedFieldType
} from '@shared/models/calculated-field.models';
import { EntityId } from '@shared/models/id/entity-id';
import { BaseData } from '@shared/models/base-data';
import { Observable } from 'rxjs';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import {
CalculatedFieldsTableConfig,
CalculatedFieldsTableEntity
} from '@home/components/calculated-fields/calculated-fields-table-config';
import { TenantId } from '@shared/models/id/tenant-id';
import { StringItemsOption } from '@shared/components/string-items-list.component';
import { RelationTypes } from '@shared/models/relation.models';
import {
AlarmRule,
AlarmRuleConditionType,
alarmRuleEntityTypeList,
AlarmRuleExpressionType
} from '@shared/models/alarm-rule.models';
import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service';
import { AssetInfo } from '@shared/models/asset.models';
import { DeviceInfo } from '@shared/models/device.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { EntityService } from '@core/http/entity.service';
@Component({
selector: 'tb-alarm-rules',
templateUrl: './alarm-rules.component.html',
styleUrls: []
})
export class AlarmRulesComponent extends EntityComponent<CalculatedFieldsTableEntity> {
@Input()
standalone = false;
@Input()
entityName: string;
ownerId = new TenantId(getCurrentAuthUser(this.store).tenantId);
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
readonly EntityType = EntityType;
readonly alarmRuleEntityTypeList = alarmRuleEntityTypeList;
readonly CalculatedFieldType = CalculatedFieldType;
private cfFormService = inject(CalculatedFieldFormService);
private destroyRef = inject(DestroyRef);
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: CalculatedFieldInfo,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig,
protected fb: FormBuilder,
protected cd: ChangeDetectorRef,
private entityService: EntityService) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
}
hideDelete() {
if (this.entitiesTableConfig) {
return !this.entitiesTableConfig.deleteEnabled(this.entity);
} else {
return false;
}
}
additionalDebugActionConfig = {
...this.entitiesTableConfig.additionalDebugActionConfig,
action: () => this.entitiesTableConfig.additionalDebugActionConfig.action(
{ id: this.entity.id, ...this.entityFormValue() }, false,
(expression) => {
if (expression) {
this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression});
this.entityForm.get('configuration').markAsDirty();
}
}),
};
get entityId(): EntityId {
return this.entityForm.get('entityId').value;
}
get entitiesTableConfig(): CalculatedFieldsTableConfig {
return this.entitiesTableConfigValue;
}
changeEntity(entity: BaseData<EntityId>): void {
this.entityName = entity?.name;
}
buildForm(_entity?: CalculatedFieldInfo): FormGroup {
return inject(CalculatedFieldFormService).buildAlarmRuleForm();
}
updateForm(entity: CalculatedFieldInfo) {
const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {};
this.entityForm.patchValue({ configuration, debugSettings, entityId, ...value }, {emitEvent: false});
if (!entityId) {
this.entityForm.get('configuration').disable({emitEvent: false});
}
}
onTestScript(expression?: string): Observable<string> {
return this.cfFormService.testScript(
this.entity?.id?.id,
this.entityFormValue(),
this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig),
this.destroyRef,
expression
);
}
updateFormState() {
if (this.entityForm) {
if (this.isEditValue) {
this.entityForm.enable({emitEvent: false});
this.entityForm.get('entityId').disable({emitEvent: false});
this.getOwnerId(this.entityId);
} else {
this.entityForm.disable({emitEvent: false});
}
}
}
get arguments(): Record<string, CalculatedFieldArgument> {
return this.entityForm.get('configuration.arguments').value;
}
get predefinedTypeValues(): StringItemsOption[] {
return RelationTypes.map(type => ({
name: type,
value: type
}));
}
get configFormGroup(): FormGroup {
return this.entityForm.get('configuration') as FormGroup;
}
public removeClearAlarmRule() {
this.configFormGroup.patchValue({clearRule: null});
this.entityForm.markAsDirty();
}
public addClearAlarmRule() {
const clearAlarmRule: AlarmRule = {
condition: {
type: AlarmRuleConditionType.SIMPLE,
expression: {
type: AlarmRuleExpressionType.SIMPLE
}
}
};
this.configFormGroup.patchValue({clearRule: clearAlarmRule});
}
getOwnerId(entityId: EntityId) {
if (entityId?.entityType === EntityType.DEVICE || entityId?.entityType === EntityType.ASSET) {
this.entityService.getEntity(entityId.entityType, entityId.id, { ignoreLoading: true, ignoreErrors: true }).subscribe(
(entity: AssetInfo | DeviceInfo) => {
if (this.isAssignedToCustomer(entity)) {
this.ownerId = entity.customerId;
}
}
);
}
}
private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean {
return entity && entity.customerId && entity.customerId.id !== NULL_UUID;
}
}

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

@ -184,11 +184,13 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
takeUntilDestroyed()
).subscribe((mode) => {
this.updateStaticValueValidator(AlarmRuleConditionType.DURATION, mode);
this.updateSpecText(this.conditionFormGroup.get('type').value);
});
this.repeatingDynamicModeControl.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((mode) => {
this.updateStaticValueValidator(AlarmRuleConditionType.REPEATING, mode);
this.updateSpecText(this.conditionFormGroup.get('type').value);
});
this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE);
@ -289,20 +291,20 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent<CfAlarm
duringText = this.translate.instant('timewindow.days', {days: value.staticValue});
break;
}
if (value.dynamicValueArgument) {
if (this.durationDynamicModeControl.value) {
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 {
this.specText = this.translate.instant('alarm-rule.condition-during', {
during: duringText
during: duringText.trim()
});
}
break;
case AlarmRuleConditionType.REPEATING:
if (count.dynamicValueArgument) {
if (this.repeatingDynamicModeControl.value) {
this.specText = this.translate.instant('alarm-rule.condition-repeat-times-dynamic', {
attribute: `${count.dynamicValueArgument}`
attribute: `${count.dynamicValueArgument ?? ''}`
});
} else {
this.specText = this.translate.instant('alarm-rule.condition-repeat-times',

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

@ -15,15 +15,14 @@
limitations under the License.
-->
<div class="tb-alarm-rule-condition flex flex-col gap-4 min-w-0" [formGroup]="alarmRuleConditionFormGroup">
<div class="tb-alarm-rule-condition flex min-w-0 flex-col gap-4" [formGroup]="alarmRuleConditionFormGroup">
<div class="tb-form-row column-xs">
<div class="min-w-40 xs:min-w-fit">{{ 'alarm-rule.condition' | translate }}</div>
<button [disabled]="disabled"
type="button"
<button type="button"
class="tb-alarm-rule-condition-button"
mat-stroked-button [color]="conditionSet() && this.filtersArgumentsValid ? 'primary' : 'warn'"
mat-stroked-button [color]="conditionSet() || this.filtersArgumentsValid ? 'primary' : 'warn'"
(click)="openFilterDialog($event)">
<div class="flex items-center gap-2 justify-between">
<div class="flex items-center justify-between gap-2">
<tb-alarm-rule-filter-text [alarmRuleExpression]="alarmRuleConditionFormGroup.get('expression').value"
[arguments]="arguments"
[disabled]="disabled"
@ -35,15 +34,14 @@
</tb-alarm-rule-filter-text>
<mat-icon [color]="disabled ? '' : (conditionSet() && this.filtersArgumentsValid ? 'primary' : 'warn')"
class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">
{{ conditionSet() ? 'edit' : 'add' }}
{{ disabled ? 'visibility' : (conditionSet() ? 'edit' : 'add') }}
</mat-icon>
</div>
</button>
</div>
<div class="tb-form-row column-xs">
<div class="min-w-40 xs:min-w-fit">{{ 'alarm-rule.schedule-title' | translate }}</div>
<button [disabled]="disabled"
[color]="schedulerArgumentsValid ? 'primary' : 'warn'"
<button [color]="schedulerArgumentsValid ? 'primary' : 'warn'"
type="button"
class="tb-alarm-rule-condition-button"
mat-stroked-button
@ -52,7 +50,7 @@
<span class="tb-alarm-rule-condition-label" tbTruncateWithTooltip [innerHTML]="scheduleText"></span>
<mat-icon [color]="disabled ? '' : (schedulerArgumentsValid ? 'primary' : 'warn')"
class="tb-mat-20 tb-alarm-rule-schedule-edit-icon">
edit
{{ disabled ? 'visibility' : 'edit' }}
</mat-icon>
</div>
</button>

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

@ -137,19 +137,27 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
writeValue(value: AlarmRuleCondition): void {
this.modelValue = value;
this.updateConditionInfo();
if (value) {
this.onValidatorChange();
}
this.recalculateArgumentValidity();
}
ngOnChanges(changes: SimpleChanges) {
if (changes.arguments) {
if (changes.arguments && !changes.arguments.firstChange && this.modelValue) {
this.onValidatorChange();
if (changes.arguments && !changes.arguments.firstChange) {
this.recalculateArgumentValidity();
}
}
}
private recalculateArgumentValidity(): void {
if (!this.modelValue || !this.arguments) {
this.filtersArgumentsValid = true;
this.schedulerArgumentsValid = true;
return;
}
this.filtersArgumentsValid = this.areFilterAndPredicateArgumentsValid(this.modelValue, this.arguments);
this.schedulerArgumentsValid = this.isScheduleArgumentValid(this.modelValue, Object.keys(this.arguments));
}
private isScheduleArgumentValid(obj: any, validArguments: string[]): boolean {
const arg = obj?.schedule?.dynamicValueArgument;
return !arg || validArguments.includes(arg);
@ -173,21 +181,14 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
return true;
}
public conditionSet() {
return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters);
public conditionSet(): boolean {
return !!this.modelValue && !!(this.modelValue.expression?.expression || this.modelValue.expression?.filters);
}
public validate(control: AbstractControl): ValidationErrors | null {
this.filtersArgumentsValid = this.areFilterAndPredicateArgumentsValid(this.modelValue, this.arguments);
this.schedulerArgumentsValid = this.isScheduleArgumentValid(this.modelValue, Object.keys(this.arguments));
this.onValidatorChange = () => {
control.updateValueAndValidity({ emitEvent: true });
};
return this.conditionSet() && this.filtersArgumentsValid && this.schedulerArgumentsValid ? null : {
alarmRuleCondition: {
valid: false,
}
};
const hasCondition = this.conditionSet();
const argsValid = this.filtersArgumentsValid && this.schedulerArgumentsValid;
return (hasCondition && argsValid) ? null : { alarmRuleCondition: { valid: false } };
}
public openFilterDialog($event: Event) {
@ -208,7 +209,6 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
if (result) {
this.modelValue = {...this.modelValue, ...result};
this.updateModel();
this.cd.detectChanges();
}
});
}
@ -274,10 +274,10 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
private updateModel() {
this.updateConditionInfo();
this.recalculateArgumentValidity();
this.propagateChange(this.modelValue);
if (this.modelValue) {
this.onValidatorChange();
}
this.onValidatorChange();
this.cd.detectChanges();
}
public openScheduleDialog($event: Event) {
@ -297,13 +297,12 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
if (result) {
this.modelValue.schedule = result;
this.updateModel();
this.cd.detectChanges();
}
});
}
private updateScheduleText() {
let schedule = this.modelValue?.schedule;
const schedule = this.modelValue?.schedule;
this.scheduleText = '';
if (isDefinedAndNotNull(schedule)) {
if (schedule.dynamicValueArgument) {

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-form-panel no-border no-padding" [formGroup]="alarmRuleFormGroup">
<div class="tb-form-panel no-border no-padding" [class.disabled]="disabled" [formGroup]="alarmRuleFormGroup">
<tb-cf-alarm-rule-condition formControlName="condition" [arguments]="arguments" [required]="required" [testScript]="testScript" [isClearCondition]="isClearCondition">
</tb-cf-alarm-rule-condition>
<div class="tb-form-row space-between column-xs">

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

@ -114,13 +114,13 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida
}
createAlarmRulesControls.push(this.fb.group({
severity: [severity, Validators.required],
alarmRule: [createAlarmRule, Validators.required]
alarmRule: [{value: createAlarmRule, disabled: this.disabled}, Validators.required]
}));
});
}
const formArray = this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray;
formArray.clear();
createAlarmRulesControls.forEach(c => formArray.push(c));
formArray.clear({emitEvent: false});
createAlarmRulesControls.forEach(c => formArray.push(c, {emitEvent: false}));
if (this.disabled) {
this.createAlarmRulesFormGroup.disable({emitEvent: false});
} else {

19
ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts

@ -32,13 +32,15 @@ import {
AlarmCommentInfo,
AlarmCommentType,
AlarmMessage,
AlarmSeverity,
alarmSeverityTranslations,
getUserDisplayName
} from '@shared/models/alarm.models';
import { UtilsService } from '@core/services/utils.service';
import { EntityType } from '@shared/models/entity-type.models';
import { DatePipe } from '@angular/common';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { isNotEmptyStr } from '@core/utils';
import { deepClone, isNotEmptyStr } from '@core/utils';
interface AlarmCommentsDisplayData {
commentId?: string;
@ -153,8 +155,21 @@ export class AlarmCommentComponent implements OnInit {
private parseSystemComment(alarm: AlarmCommentInfo): string {
const subTypeKey = alarm.comment?.subtype;
if (subTypeKey && AlarmMessage[subTypeKey]) {
const alarmComment = deepClone(alarm.comment);
const translationKey = AlarmMessage[subTypeKey];
return this.translate.instant(translationKey, alarm.comment);
if (alarmComment?.newSeverity) {
alarmComment.newSeverity =
(alarmSeverityTranslations.has(alarmComment.newSeverity)
? this.translate.instant(alarmSeverityTranslations.get(alarmComment.newSeverity))
: alarmComment.newSeverity) as AlarmSeverity;
}
if (alarmComment?.oldSeverity) {
alarmComment.oldSeverity =
(alarmSeverityTranslations.has(alarmComment.oldSeverity)
? this.translate.instant(alarmSeverityTranslations.get(alarmComment.oldSeverity))
: alarmComment.oldSeverity) as AlarmSeverity;
}
return this.translate.instant(translationKey, alarmComment);
}
return alarm.comment.text;
}

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

@ -0,0 +1,146 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{ 'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'export')"
[class.!hidden]="isEdit">
{{ 'action.export' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{ 'action.delete' | translate }}
</button>
</div>
<div class="mat-padding flex flex-1 flex-col" [formGroup]="entityForm">
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title mb-4">{{ 'common.general' | translate }}</div>
<div class="flex items-center gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'entity-field.title' | translate }}</mat-label>
<input matInput maxlength="255" formControlName="name" required>
@if (entityForm.get('name').errors && entityForm.get('name').touched) {
<mat-error>
@if (entityForm.get('name').hasError('required')) {
{{ 'common.hint.title-required' | translate }}
} @else if (entityForm.get('name').hasError('pattern')) {
{{ 'common.hint.title-pattern' | translate }}
} @else if (entityForm.get('name').hasError('maxlength')) {
{{ 'common.hint.title-max-length' | translate }}
}
</mat-error>
}
<mat-hint></mat-hint>
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
style="margin-bottom: 22px"
[entityType]="EntityType.CALCULATED_FIELD"
[additionalActionConfig]="additionalDebugActionConfig"
/>
</div>
<tb-entity-select formControlName="entityId"
appearance="outline"
required
entityTypeLabel="{{ 'entity.entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="calculatedFieldsEntityTypeList"
(entityChanged)="changeEntity($event)">
</tb-entity-select>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'common.type' | translate }}</mat-label>
<mat-select formControlName="type">
<mat-select-trigger>
{{ CalculatedFieldTypeTranslations.has(entityForm.get('type').value)
? (CalculatedFieldTypeTranslations.get(entityForm.get('type').value).name | translate)
: entityForm.get('type').value }}
</mat-select-trigger>
@for (type of fieldTypes; track type) {
<mat-option [value]="type" class="py-1">
<span matListItemTitle>
{{ CalculatedFieldTypeTranslations.get(type).name | translate }}
</span>
<br>
<small matListItemMeta style="color: inherit">
{{ CalculatedFieldTypeTranslations.get(type).hint | translate }}
</small>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@switch (entityForm.get('type').value) {
@case (CalculatedFieldType.GEOFENCING) {
<tb-geofencing-configuration formControlName="configuration"
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[ownerId]="ownerId"
[tenantId]="tenantId"
></tb-geofencing-configuration>
}
@case (CalculatedFieldType.PROPAGATION) {
<tb-propagation-configuration formControlName="configuration"
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="tenantId"
[ownerId]="ownerId"
[testScript]="onTestScript.bind(this)"
></tb-propagation-configuration>
}
@case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) {
<tb-related-entities-aggregation-component formControlName="configuration"
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="tenantId"
[testScript]="onTestScript.bind(this)"
></tb-related-entities-aggregation-component>
}
@case (CalculatedFieldType.ENTITY_AGGREGATION) {
<tb-entity-aggregation-component formControlName="configuration"
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="tenantId"
></tb-entity-aggregation-component>
}
@default {
<tb-simple-configuration formControlName="configuration"
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[ownerId]="ownerId"
[tenantId]="tenantId"
[isScript]="entityForm.get('type').value === CalculatedFieldType.SCRIPT"
[testScript]="onTestScript.bind(this)"
></tb-simple-configuration>
}
}
</div>
</div>

52
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss

@ -0,0 +1,52 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
::ng-deep {
.tbel-script-lang-chip {
line-height: 20px;
font-size: 14px;
font-weight: 500;
color: white;
border-radius: 100px;
width: 70px;
min-width: 70px;
display: flex;
justify-content: center;
margin-top: 2px;
margin-right: 4px;
}
.tb-js-func {
.ace_tb {
&.ace_calculated-field {
&-ctx {
color: #C52F00;
}
&-args {
color: #185F2A;
}
&-key {
color: #c24c1a;
}
&-time-window, &-values, &-func, &-value, &-ts, &-latestTs {
color: #7214D0;
}
&-start-ts, &-end-ts {
color: #2CAA00;
}
}
}
}
}

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

@ -0,0 +1,167 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, DestroyRef, inject, Inject, Input } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { EntityComponent } from '../../components/entity/entity.component';
import { FormBuilder, FormGroup } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import {
CalculatedFieldConfiguration,
CalculatedFieldInfo,
calculatedFieldsEntityTypeList,
CalculatedFieldType,
calculatedFieldTypes,
CalculatedFieldTypeTranslations
} from '@shared/models/calculated-field.models';
import { EntityId } from '@shared/models/id/entity-id';
import { BaseData } from '@shared/models/base-data';
import { Observable } from 'rxjs';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import {
CalculatedFieldsTableConfig,
CalculatedFieldsTableEntity
} from '@home/components/calculated-fields/calculated-fields-table-config';
import { TenantId } from '@shared/models/id/tenant-id';
import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service';
import { AssetInfo } from '@shared/models/asset.models';
import { DeviceInfo } from '@shared/models/device.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { EntityService } from '@core/http/entity.service';
@Component({
selector: 'tb-calculated-field',
templateUrl: './calculated-field.component.html',
styleUrls: ['./calculated-field.component.scss']
})
export class CalculatedFieldComponent extends EntityComponent<CalculatedFieldsTableEntity> {
@Input()
standalone = false;
@Input()
entityName: string;
disabledConfiguration = false;
ownerId = new TenantId(getCurrentAuthUser(this.store).tenantId);
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
readonly EntityType = EntityType;
readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList;
readonly CalculatedFieldType = CalculatedFieldType;
readonly fieldTypes = calculatedFieldTypes;
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
private cfFormService = inject(CalculatedFieldFormService);
private destroyRef = inject(DestroyRef);
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: CalculatedFieldInfo,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig,
protected fb: FormBuilder,
protected cd: ChangeDetectorRef,
private entityService: EntityService) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
}
hideDelete() {
if (this.entitiesTableConfig) {
return !this.entitiesTableConfig.deleteEnabled(this.entity);
} else {
return false;
}
}
additionalDebugActionConfig = {
...this.entitiesTableConfig.additionalDebugActionConfig,
action: () => this.entitiesTableConfig.additionalDebugActionConfig.action(
{ id: this.entity.id, ...this.entityFormValue() }, false,
(expression) => {
if (expression) {
this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression});
this.entityForm.get('configuration').markAsDirty();
}
}),
};
get entityId(): EntityId {
return this.entityForm.get('entityId').value;
}
get entitiesTableConfig(): CalculatedFieldsTableConfig {
return this.entitiesTableConfigValue;
}
changeEntity(entity: BaseData<EntityId>): void {
this.entityName = entity?.name;
}
buildForm(_entity?: CalculatedFieldInfo): FormGroup {
const form = inject(CalculatedFieldFormService).buildForm();
inject(CalculatedFieldFormService).setupTypeChange(form, inject(DestroyRef), () => this.isEditValue);
return form;
}
updateForm(entity: CalculatedFieldInfo) {
const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {};
const preparedConfig = this.cfFormService.prepareConfig(configuration);
this.entityForm.patchValue({ type }, {emitEvent: false, onlySelf: true});
setTimeout(() => {
this.entityForm.patchValue({ configuration: preparedConfig, debugSettings, entityId, ...value }, {emitEvent: false});
});
}
onTestScript(expression?: string): Observable<string> {
return this.cfFormService.testScript(
this.entity?.id?.id,
this.entityFormValue(),
this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig),
this.destroyRef,
expression
);
}
updateFormState() {
if (this.entityForm) {
if (this.isEditValue) {
this.entityForm.enable({emitEvent: false});
this.entityForm.get('entityId').disable({emitEvent: false});
this.getOwnerId(this.entityId);
} else {
this.entityForm.disable({emitEvent: false});
}
}
}
getOwnerId(entityId: EntityId) {
if (entityId?.entityType === EntityType.DEVICE || entityId?.entityType === EntityType.ASSET) {
this.entityService.getEntity(entityId.entityType, entityId.id, { ignoreLoading: true, ignoreErrors: true }).subscribe(
(entity: AssetInfo | DeviceInfo) => {
if (this.isAssignedToCustomer(entity)) {
this.ownerId = entity.customerId;
}
}
);
}
}
private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean {
return entity && entity.customerId && entity.customerId.id !== NULL_UUID;
}
}

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

@ -50,6 +50,7 @@ import {
import {
CalculatedFieldsFilterConfigComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-filter-config.component';
import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component';
@NgModule({
declarations: [
@ -57,7 +58,8 @@ import {
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldsHeaderComponent,
CalculatedFieldsFilterConfigComponent
CalculatedFieldsFilterConfigComponent,
CalculatedFieldComponent,
],
imports: [
CommonModule,
@ -72,6 +74,7 @@ import {
exports: [
CalculatedFieldDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldComponent,
]
})
export class CalculatedFieldsModule {}

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

@ -20,7 +20,7 @@ import {
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import { Direction } from '@shared/models/page/sort-order';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
@ -35,7 +35,7 @@ import { DestroyRef, Renderer2 } from '@angular/core';
import { EntityDebugSettings } from '@shared/models/entity.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { catchError, filter, switchMap, tap } from 'rxjs/operators';
import { catchError, filter, first, switchMap, tap } from 'rxjs/operators';
import {
ArgumentEntityType,
ArgumentType,
@ -46,6 +46,7 @@ import {
CalculatedFieldsQuery,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
debugCfActionEnabled,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
PropagationWithExpression,
@ -62,20 +63,26 @@ import { EntityDebugSettingsService } from '@home/components/entity/debug/entity
import { DatePipe } from '@angular/common';
import { UtilsService } from "@core/services/utils.service";
import { ActionNotificationShow } from "@core/notification/notification.actions";
import { CalculatedFieldEventBody, DebugEventType, Event as DebugEvent, EventType } from '@shared/models/event.models';
import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models';
import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-dialog.component';
import {
CalculatedFieldsHeaderComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-header.component';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component';
import { Router } from '@angular/router';
import { CalculatedFieldsTabsComponent } from '@home/pages/calculated-fields/calculated-fields-tabs.component';
type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo;
export type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo;
export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFieldsTableEntity> {
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
additionalDebugActionConfig = {
title: this.translate.instant('action.see-debug-events'),
action: (calculatedField: CalculatedFieldsTableEntity) => this.openDebugEventsDialog.call(this, null, calculatedField),
action: (calculatedField: CalculatedFieldsTableEntity,
openCalculatedFieldEdit = true,
afterCloseCallback?: (expression: string) => void) => this.openDebugEventsDialog.call(this, null, calculatedField, openCalculatedFieldEdit, afterCloseCallback),
};
calculatedFieldFilterConfig: CalculatedFieldsQuery;
@ -93,30 +100,35 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService,
private utilsService: UtilsService,
private router: Router,
public pageMode = false,
) {
super();
if (this.pageMode) {
this.headerComponent = CalculatedFieldsHeaderComponent;
this.handleRowClick = ($event, entity) => {
this.editCalculatedField($event, entity);
this.rowPointer = true;
return true;
};
this.entityComponent = CalculatedFieldComponent;
this.entityTabsComponent = CalculatedFieldsTabsComponent;
this.rowPointer = true;
}
this.tableTitle = this.pageMode ? '' : this.translate.instant('entity.type-calculated-fields');
this.detailsPanelEnabled = false;
this.tableTitle = this.translate.instant('entity.type-calculated-fields');
this.detailsPanelEnabled = this.pageMode;
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD);
this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink);
this.addEntity = this.getCalculatedFieldDialog.bind(this);
this.saveEntity = (cf) => this.calculatedFieldsService.saveCalculatedField(cf);
this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id);
this.deleteEntityTitle = (field) => this.translate.instant('calculated-fields.delete-title', {title: field.name});
this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text');
this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count});
this.deleteEntitiesContent = () => this.translate.instant('calculated-fields.delete-multiple-text');
this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id);
this.onEntityAction = action => this.onCFAction(action);
this.addActionDescriptors = [
{
name: this.translate.instant('calculated-fields.create'),
@ -163,7 +175,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
name: this.translate.instant('entity-view.events'),
icon: 'mdi:clipboard-text-clock',
isEnabled: () => true,
onAction: ($event, entity) => this.openDebugEventsDialog($event, entity),
onAction: ($event, entity) =>
this.pageMode ? this.openDebugTab($event, entity) : this.openDebugEventsDialog($event, entity),
},
{
name: '',
@ -172,14 +185,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
isEnabled: () => true,
iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline',
onAction: ($event, entity) => this.onOpenDebugConfig($event, entity),
},
{
}
);
if (!this.pageMode) {
this.cellActionDescriptors.push({
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: ($event, entity) => this.editCalculatedField($event, entity),
}
);
})
}
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedFieldsTableEntity>> {
@ -188,7 +203,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.calculatedFieldsService.getCalculatedFieldsByEntityId(this.entityId, pageLink);
}
onOpenDebugConfig($event: Event, calculatedField: CalculatedFieldsTableEntity): void {
private onOpenDebugConfig($event: Event, calculatedField: CalculatedFieldsTableEntity): void {
$event?.stopPropagation();
const { debugSettings = {}, id } = calculatedField;
const additionalActionConfig = {
@ -212,6 +227,22 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}, $event.target as Element);
}
private openDebugTab($event: Event, calculatedField: CalculatedFieldsTableEntity) {
const table = this.getTable();
if (!table.isDetailsOpen) {
table.toggleEntityDetails($event, calculatedField);
if (table.entityDetailsPanel.matTabGroup._tabs.length > 1) {
table.entityDetailsPanel.matTabGroup.selectedIndex = 1;
} else {
table.entityDetailsPanel.matTabGroup._tabs.changes.pipe(
first()
).subscribe(() => {
table.entityDetailsPanel.matTabGroup.selectedIndex = 1;
})
}
}
}
private editCalculatedField($event: Event, calculatedField: CalculatedFieldsTableEntity, isDirty = false): void {
$event?.stopPropagation();
this.getCalculatedFieldDialog(calculatedField, 'action.apply', isDirty)
@ -222,7 +253,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
});
}
private getCalculatedFieldDialog(value?: CalculatedFieldsTableEntity, buttonTitle = 'action.add', isDirty = false): Observable<CalculatedField> {
private getCalculatedFieldDialog(value?: CalculatedFieldsTableEntity, buttonTitle = 'action.add', isDirty = false, disabledSelectType = false): Observable<CalculatedField> {
const entityId = this.entityId || value?.entityId;
const entityName = this.entityName || (value as CalculatedFieldInfo)?.entityName;
return this.dialog.open<CalculatedFieldDialogComponent, CalculatedFieldDialogData, CalculatedField>(CalculatedFieldDialogComponent, {
@ -234,10 +265,11 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
entityId,
entityName,
tenantId: this.tenantId,
ownerId: this.ownerId,
ownerId: this.ownerId ?? {entityType: EntityType.TENANT, id: this.tenantId},
additionalDebugActionConfig: this.additionalDebugActionConfig,
getTestScriptDialogFn: this.getTestScriptDialog.bind(this),
isDirty,
disabledSelectType
},
enterAnimationDuration: isDirty ? 0 : null,
})
@ -245,17 +277,11 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
.pipe(filter(Boolean));
}
private openDebugEventsDialog($event: Event, calculatedField: CalculatedFieldsTableEntity): void {
private openDebugEventsDialog($event: Event, calculatedField: CalculatedFieldsTableEntity, openCalculatedFieldEdit = true, afterCloseCallback?: (expression: string) => void ): void {
$event?.stopPropagation();
const debugActionEnabledFn = (event: DebugEvent) => {
return (calculatedField.type === CalculatedFieldType.SCRIPT ||
(calculatedField.type === CalculatedFieldType.PROPAGATION &&
calculatedField.configuration.applyExpressionToResolvedArguments)
) && !!(event as DebugEvent).body.arguments;
};
const onDebugEventSelected = (event: CalculatedFieldEventBody, dialogRef: MatDialogRef<EventsDialogComponent, string>) => {
this.getTestScriptDialog(calculatedField, JSON.parse(event.arguments))
this.getTestScriptDialog(calculatedField, JSON.parse(event.arguments), openCalculatedFieldEdit)
.subscribe(expression => dialogRef.close(expression));
};
@ -270,11 +296,15 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
disabledEventTypes:[EventType.LC_EVENT, EventType.ERROR, EventType.STATS],
defaultEventType: DebugEventType.DEBUG_CALCULATED_FIELD,
onDebugEventSelected,
debugActionEnabledFn
debugActionDisabled: !debugCfActionEnabled(calculatedField)
}
})
.afterClosed()
.subscribe();
.subscribe(value => {
if (afterCloseCallback) {
afterCloseCallback(value)
}
});
}
private exportCalculatedField($event: Event, calculatedField: CalculatedFieldsTableEntity): void {
@ -303,7 +333,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
.pipe(
filter(Boolean),
switchMap(calculatedField => {
if (calculatedField.type === CalculatedFieldType.ALARM) {
if (calculatedField.type === CalculatedFieldType.ALARM || !CalculatedFieldType[calculatedField.type]) {
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant('calculated-fields.hint.import-invalid-calculated-field-type'),
type: 'error',
@ -315,7 +345,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
return of(calculatedField);
}),
switchMap(calculatedField => this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)),
switchMap(calculatedField => this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true, true)),
filter(Boolean),
switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)),
filter(Boolean),
@ -354,7 +384,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
).subscribe(() => this.updateData());
}
private getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable<string> {
getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable<string> {
if (
calculatedField.type === CalculatedFieldType.SCRIPT ||
calculatedField.type === CalculatedFieldType.RELATED_ENTITIES_AGGREGATION ||
@ -394,4 +424,22 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
return of(null);
}
}
private openCalculatedField($event: Event, entity: CalculatedFieldsTableEntity) {
$event?.stopPropagation();
const url = this.router.createUrlTree(['calculatedFields', entity.id.id]);
this.router.navigateByUrl(url);
}
private onCFAction(action: EntityAction<CalculatedFieldsTableEntity>): boolean {
switch (action.action) {
case 'open':
this.openCalculatedField(action.event, action.entity);
return true;
case 'export':
this.exportCalculatedField(action.event, action.entity);
return true;
}
return false;
}
}

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

@ -36,7 +36,7 @@ import { ImportExportService } from '@shared/import-export/import-export.service
import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service';
import { DatePipe } from '@angular/common';
import { UtilsService } from "@core/services/utils.service";
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'tb-calculated-fields-table',
@ -70,6 +70,7 @@ export class CalculatedFieldsTableComponent {
private utilsService: UtilsService,
private destroyRef: DestroyRef,
private route: ActivatedRoute,
private router: Router
) {
this.pageMode = !!this.route.snapshot.data.isPage;
effect(() => {
@ -88,6 +89,7 @@ export class CalculatedFieldsTableComponent {
this.importExportService,
this.entityDebugSettingsService,
this.utilsService,
this.router,
this.pageMode,
);
this.cd.markForCheck();

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

@ -188,13 +188,15 @@
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="saveArgument()"
[disabled]="argumentFormGroup.invalid || !argumentFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
@if (!readonly) {
<button mat-raised-button
color="primary"
type="button"
(click)="saveArgument()"
[disabled]="argumentFormGroup.invalid || !argumentFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
}
</div>
</div>

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

@ -88,6 +88,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
maxlength: 'calculated-fields.hint.argument-name-max-length',
forbidden: 'calculated-fields.hint.argument-name-forbidden'
};
@Input() readonly = false;
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
@ -176,6 +177,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.isScript);
if (this.readonly) {
this.argumentType.disable({emitEvent: false});
this.argumentFormGroup.disable({emitEvent: false});
}
}
ngAfterViewInit(): void {
@ -259,7 +265,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
};
}
if (!onInit) {
this.argumentFormGroup.get('refEntityKey').get('key').setValue('');
this.argumentFormGroup.get('refEntityKey').get('key').setValue('', {emitEvents: !this.watchKeyChange});
if (this.watchKeyChange && this.argumentFormGroup.get('argumentName').pristine) {
this.argumentFormGroup.get('argumentName').markAsUntouched({emitEvent: false});
this.argumentFormGroup.get('argumentName').setValue('', {emitEvent: false});
}
} else if (this.predefinedEntityFilter) {
entityFilter = this.predefinedEntityFilter;
}

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

@ -93,22 +93,33 @@
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let argument;">
<div class="tb-form-table-row-cell-buttons min-w-20">
<button type="button"
mat-icon-button
#button
[disabled]="disable"
(click)="manageArgument($event, button, argument)"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="isEditButtonShowBadge(argument)"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*"
>
edit
</mat-icon>
</button>
@if (disable) {
<button type="button"
mat-icon-button
#previewButton
(click)="manageArgument($event, previewButton, argument, true)"
[matTooltip]="'calculated-fields.preview' | translate"
matTooltipPosition="above">
<mat-icon>visibility</mat-icon>
</button>
} @else {
<button type="button"
mat-icon-button
#button
[disabled]="disable"
(click)="manageArgument($event, button, argument)"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="isEditButtonShowBadge(argument)"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*"
>
edit
</mat-icon>
</button>
}
<button type="button"
mat-icon-button
[disabled]="disable"

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

@ -154,7 +154,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
validate(): ValidationErrors | null {
this.updateErrorText();
return this.errorText ? { argumentsFormArray: false } : null;
return this.errorText || !this.argumentsFormArray.controls.length ? { argumentsFormArray: false } : null;
}
setDisabledState(isDisabled: boolean): void {
@ -168,7 +168,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.argumentsFormArray.markAsDirty();
}
manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue): void {
manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, readonly: boolean = false): void {
$event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide();
@ -190,6 +190,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
ownerId: this.ownerId,
watchKeyChange: this.watchKeyChange,
usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName),
readonly
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,
@ -239,7 +240,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
}
writeValue(argumentsObj: Record<string, CalculatedFieldArgument>): void {
this.argumentsFormArray.clear();
this.argumentsFormArray.clear({emitEvent: false});
this.populateArgumentsFormArray(argumentsObj);
this.updateEntityNameMap(this.argumentsFormArray.value);
}
@ -249,7 +250,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
}
protected changeIsScriptMode(): void {
this.argumentsFormArray.updateValueAndValidity();
this.argumentsFormArray.updateValueAndValidity({emitEvent: !this.disable});
}
protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean {
@ -264,7 +265,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
};
this.argumentsFormArray.push(this.fb.control(value), { emitEvent: false });
});
this.argumentsFormArray.updateValueAndValidity();
this.updateDataSource(this.argumentsFormArray.value);
}
private updateEntityNameMap(values: CalculatedFieldArgumentValue[]): void {

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

@ -56,40 +56,36 @@
/>
</div>
@if (!data.entityId) {
<div class="flex flex-row gap-4 xs:flex-col" formGroupName="entityId">
<tb-entity-type-select #entityTypeSelect
appearance="outline"
class="flex-1"
showLabel
required
label="{{ 'entity.entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="calculatedFieldsEntityTypeList"
formControlName="entityType">
</tb-entity-type-select>
@if (fieldFormGroup.get('entityId.entityType').value) {
<tb-entity-autocomplete #entityAutocompleteComponent
formControlName="id"
appearance="outline"
class="flex-1"
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="fieldFormGroup.get('entityId.entityType').value"
(entityChanged)="changeEntity($event)"
/>
}
</div>
<tb-entity-select formControlName="entityId"
appearance="outline"
required
entityTypeLabel="{{ 'entity.entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="calculatedFieldsEntityTypeList"
[defaultEntityType]="EntityType.DEVICE_PROFILE"
(entityChanged)="changeEntity($event)">
</tb-entity-select>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'common.type' | translate }}</mat-label>
<mat-select formControlName="type">
<mat-select-trigger>
{{ CalculatedFieldTypeTranslations.has(fieldFormGroup.get('type').value)
? (CalculatedFieldTypeTranslations.get(fieldFormGroup.get('type').value).name | translate)
: fieldFormGroup.get('type').value }}
</mat-select-trigger>
@for (type of fieldTypes; track type) {
<mat-option [value]="type">{{ CalculatedFieldTypeTranslations.get(type).name | translate}}</mat-option>
<mat-option [value]="type" class="py-1">
<span matListItemTitle>
{{ CalculatedFieldTypeTranslations.get(type).name | translate}}
</span>
<br>
<small matListItemMeta style="color: inherit">
{{ CalculatedFieldTypeTranslations.get(type).hint | translate }}
</small>
</mat-option>
}
</mat-select>
@if (CalculatedFieldTypeTranslations.get(fieldFormGroup.get('type').value).hint) {
<mat-hint>{{ CalculatedFieldTypeTranslations.get(fieldFormGroup.get('type').value).hint | translate }}</mat-hint>
}
</mat-form-field>
</div>
@switch (fieldFormGroup.get('type').value) {
@ -98,7 +94,7 @@
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[ownerId]="data.ownerId"
[ownerId]="ownerId"
[tenantId]="data.tenantId"
></tb-geofencing-configuration>
}
@ -108,7 +104,7 @@
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="data.tenantId"
[ownerId]="data.ownerId"
[ownerId]="ownerId"
[testScript]="onTestScript.bind(this)"
></tb-propagation-configuration>
}
@ -134,7 +130,7 @@
[class.tb-form-panel-disabled]="disabledConfiguration"
[entityId]="entityId"
[entityName]="entityName"
[ownerId]="data.ownerId"
[ownerId]="ownerId"
[tenantId]="data.tenantId"
[isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT"
[testScript]="onTestScript.bind(this)"

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

@ -14,11 +14,10 @@
/// limitations under the License.
///
import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import {
@ -28,21 +27,21 @@ import {
CalculatedFieldTestScriptFn,
CalculatedFieldType,
calculatedFieldTypes,
CalculatedFieldTypeTranslations,
OutputStrategyType
CalculatedFieldTypeTranslations
} from '@shared/models/calculated-field.models';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { AliasEntityType, EntityType } from '@shared/models/entity-type.models';
import { pairwise, switchMap } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { Observable } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { deepTrim, isDefined } from '@core/utils';
import { EntityTypeSelectComponent } from '@shared/components/entity/entity-type-select.component';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { deepTrim } from '@core/utils';
import { BaseData } from '@shared/models/base-data';
import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service';
import { FormGroup } from '@angular/forms';
import { AssetInfo } from '@shared/models/asset.models';
import { DeviceInfo } from '@shared/models/device.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
export interface CalculatedFieldDialogData {
value?: CalculatedField;
@ -54,6 +53,7 @@ export interface CalculatedFieldDialogData {
additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>;
getTestScriptDialogFn: CalculatedFieldTestScriptFn;
isDirty?: boolean;
disabledSelectType?: boolean;
}
@Component({
@ -64,16 +64,7 @@ export interface CalculatedFieldDialogData {
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
entityId: this.fb.group({
entityType: this.fb.control<EntityType | AliasEntityType | null>(EntityType.DEVICE_PROFILE, Validators.required),
id: [null as null | string, Validators.required],
}),
type: [CalculatedFieldType.SIMPLE],
debugSettings: [],
configuration: this.fb.control<CalculatedFieldConfiguration>({} as CalculatedFieldConfiguration),
});
fieldFormGroup: FormGroup;
additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig,
@ -81,6 +72,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
} : null;
entityName = this.data.entityName;
ownerId = this.data.ownerId;
disabledConfiguration = false;
isLoading = false;
@ -91,18 +83,16 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
readonly fieldTypes = calculatedFieldTypes;
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
@ViewChild('entityTypeSelect') entityTypeSelect: EntityTypeSelectComponent;
@ViewChild('entityAutocompleteComponent') entityAutocompleteComponent: EntityAutocompleteComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData,
protected dialogRef: MatDialogRef<CalculatedFieldDialogComponent, CalculatedField>,
private calculatedFieldsService: CalculatedFieldsService,
private destroyRef: DestroyRef,
private fb: FormBuilder) {
private cfFormService: CalculatedFieldFormService) {
super(store, router, dialogRef);
this.observeType();
this.fieldFormGroup = this.cfFormService.buildForm();
this.cfFormService.setupTypeChange(this.fieldFormGroup, this.destroyRef);
this.applyDialogData();
if (this.data.isDirty) {
@ -110,7 +100,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
if (!this.data.entityId) {
this.fieldFormGroup.get('entityId.id').valueChanges.pipe(
this.fieldFormGroup.get('entityId').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((entityId) => {
this.disabledConfiguration = !entityId;
@ -118,13 +108,18 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.fieldFormGroup.get('configuration').disable({emitEvent: false});
} else {
this.fieldFormGroup.get('configuration').enable({emitEvent: false});
this.fieldFormGroup.get('configuration').updateValueAndValidity({emitEvent: false});
}
});
}
if (this.data.disabledSelectType) {
this.fieldFormGroup.get('type').disable({emitEvent: false});
}
}
get fromGroupValue(): CalculatedField {
return deepTrim(this.fieldFormGroup.value as CalculatedField);
return deepTrim(this.fieldFormGroup.getRawValue() as CalculatedField);
}
cancel(): void {
@ -142,28 +137,24 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
});
} else {
this.fieldFormGroup.get('name').markAsTouched();
this.entityTypeSelect?.markAsTouched();
this.entityAutocompleteComponent?.markAsTouched();
}
}
onTestScript(expression?: string): Observable<string> {
const calculatedFieldId = this.data.value?.id?.id;
if (calculatedFieldId) {
return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true})
.pipe(
switchMap(event => {
const args = event?.arguments ? JSON.parse(event.arguments) : null;
return this.data.getTestScriptDialogFn(this.fromGroupValue, args, false, expression);
}),
takeUntilDestroyed(this.destroyRef)
)
}
return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false, expression);
return this.cfFormService.testScript(
this.data.value?.id?.id,
this.fromGroupValue,
this.data.getTestScriptDialogFn,
this.destroyRef,
expression
);
}
changeEntity(entity: BaseData<EntityId>): void {
this.entityName = entity.name;
if (this.isAssignedToCustomer(entity as AssetInfo | DeviceInfo)) {
this.ownerId = (entity as AssetInfo | DeviceInfo).customerId;
}
}
get entityId(): EntityId {
@ -172,12 +163,8 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
private applyDialogData(): void {
const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.data.entityId, ...value } = this.data.value ?? {};
if (configuration.type !== CalculatedFieldType.ALARM) {
if (isDefined(configuration?.output) && !configuration?.output?.strategy) {
configuration.output.strategy = {type: OutputStrategyType.RULE_CHAIN};
}
}
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, entityId, ...value }, {emitEvent: false});
const preparedConfig = this.cfFormService.prepareConfig(configuration);
this.fieldFormGroup.patchValue({ configuration: preparedConfig, type, debugSettings, entityId, ...value }, {emitEvent: false});
setTimeout(() => this.fieldFormGroup.get('type').updateValueAndValidity({onlySelf: true}));
if (!this.data.entityId) {
this.fieldFormGroup.get('configuration').disable({emitEvent: false});
@ -185,15 +172,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
}
private observeType(): void {
this.fieldFormGroup.get('type').valueChanges.pipe(
pairwise(),
takeUntilDestroyed(this.destroyRef)
).subscribe(([prevType, nextType]) => {
if (![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(prevType) ||
![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(nextType)) {
this.fieldFormGroup.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false});
}
});
private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean {
return entity && entity.customerId && entity.customerId.id !== NULL_UUID;
}
}

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

@ -17,7 +17,7 @@
-->
<div [formGroup]="entityAggregationConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.argument-title-hint' | translate }}">
{{ 'calculated-fields.arguments' | translate }}
</div>
<div class="tb-form-hint tb-primary-fill hint-container">
@ -29,7 +29,7 @@
[entityName]="entityName"/>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-aggregation-metrics' | translate }}">
{{ 'calculated-fields.metrics.metrics' | translate }}
</div>
<tb-calculated-field-metrics-table formControlName="metrics"

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

@ -182,10 +182,12 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
interval: {...value.interval, allowOffsetSec: isDefinedAndNotNull(value?.interval?.offsetSec)}
}
this.entityAggregationConfiguration.patchValue(data, {emitEvent: false});
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
this.checkProduceIntermediate();
if (this.entityAggregationConfiguration.enabled) {
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
this.checkProduceIntermediate();
}
this.updatedOffsetHint();
setTimeout(() => {
this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});

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

@ -95,7 +95,9 @@
<ng-container [formGroup]="refDynamicSourceFormGroup">
<div class="tb-form-panel stroked" *ngIf="entityType === ArgumentEntityType.RelationQuery">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header>{{ 'calculated-fields.entity-zone-relationship' | translate }}</mat-expansion-panel-header>
<mat-expansion-panel-header>
<span class="tb-form-panel-title tb-required">{{ 'calculated-fields.entity-zone-relationship' | translate }}</span>
</mat-expansion-panel-header>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell tb-actions-header"></div>
@ -145,6 +147,7 @@
<button type="button"
mat-icon-button
(click)="removeKey($index)"
[disabled]="readonly"
matTooltip="{{ 'calculated-fields.delete-level' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
@ -167,7 +170,7 @@
<span>{{ 'calculated-fields.max-allowed-levels-error' | translate }}</span>
</div>
} @else {
<button type="button" mat-stroked-button color="primary" (click)="addKey()">
<button type="button" mat-stroked-button color="primary" (click)="addKey()" [disabled]="readonly">
{{ 'calculated-fields.add-level' | translate }}
</button>
}
@ -221,7 +224,7 @@
</mat-form-field>
</div>
</ng-container>
<div class="tb-form-panel stroked">
<div class="tb-form-panel stroked" [class.!hidden]="entityType !== ArgumentEntityType.RelationQuery">
<mat-slide-toggle class="mat-slide" formControlName="createRelationsWithMatchedZones" (click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.create-relation-with-matched-zones' | translate }}">
{{ 'calculated-fields.create-relation-with-matched-zones' | translate }}
@ -258,12 +261,14 @@
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="saveZone()"
[disabled]="geofencingFormGroup.invalid || !geofencingFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
@if (!readonly) {
<button mat-raised-button
color="primary"
type="button"
(click)="saveZone()"
[disabled]="geofencingFormGroup.invalid || !geofencingFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
}
</div>
</div>

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

@ -71,6 +71,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
@Input() entityName: string;
@Input() ownerId: EntityId;
@Input() usedNames: string[];
@Input() readonly = false;
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
@ -160,6 +161,10 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
this.updateEntityFilter(this.zone.refEntityId?.entityType);
if (this.readonly) {
this.geofencingFormGroup.disable({emitEvent: false});
}
}
fetchOptions(searchText: string): Observable<Array<string>> {
@ -303,6 +308,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
removeKey(index: number) {
this.levelsFormArray().removeAt(index);
this.levelsFormArray().markAsDirty();
}
addKey() {
@ -320,6 +326,6 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
}
get dragEnabled(): boolean {
return this.levelsFormArray().controls.length > 1;
return this.levelsFormArray().controls.length > 1 && !this.readonly;
}
}

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

@ -96,20 +96,31 @@
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let geofenceZone;">
<div class="tb-form-table-row-cell-buttons min-w-20">
<button type="button"
mat-icon-button
#button
(click)="manageZone($event, button, geofenceZone)"
[matTooltip]="'action.edit' | translate"
[disabled]="disable"
matTooltipPosition="above">
<mat-icon [matBadgeHidden]="geofenceZone.refEntityId?.id !== NULL_UUID"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*">
edit
</mat-icon>
</button>
@if (disable) {
<button type="button"
mat-icon-button
#previewButton
(click)="manageZone($event, previewButton, geofenceZone, true)"
[matTooltip]="'calculated-fields.preview' | translate"
matTooltipPosition="above">
<mat-icon>visibility</mat-icon>
</button>
} @else {
<button type="button"
mat-icon-button
#button
(click)="manageZone($event, button, geofenceZone)"
[matTooltip]="'action.edit' | translate"
[disabled]="disable"
matTooltipPosition="above">
<mat-icon [matBadgeHidden]="geofenceZone.refEntityId?.id !== NULL_UUID"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*">
edit
</mat-icon>
</button>
}
<button type="button"
mat-icon-button
(click)="onDelete($event, geofenceZone)"

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

@ -147,7 +147,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro
this.zoneGroupsFormArray.markAsDirty();
}
manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue): void {
manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue, readonly: boolean = false): void {
$event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide();
@ -167,6 +167,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro
entityName: this.entityName,
ownerId: this.ownerId,
usedNames: this.zoneGroupsFormArray.value.map(({ name }) => name).filter(name => name !== zone.name),
readonly
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,
@ -214,7 +215,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro
}
writeValue(zonesObj: Record<string, CalculatedFieldGeofencing>): void {
this.zoneGroupsFormArray.clear();
this.zoneGroupsFormArray.clear({emitEvent: false});
this.populateZonesFormArray(zonesObj);
this.updateEntityNameMap(this.zoneGroupsFormArray.value);
}
@ -231,7 +232,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro
};
this.zoneGroupsFormArray.push(this.fb.control(value), { emitEvent: false });
});
this.zoneGroupsFormArray.updateValueAndValidity();
this.updateDataSource(this.zoneGroupsFormArray.value);
}
private updateEntityNameMap(values: CalculatedFieldGeofencingValue[]): void {

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

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
@ -58,7 +58,7 @@ import { EntityId } from '@shared/models/id/entity-id';
}
],
})
export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnInit {
export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnChanges {
@Input({required: true})
entityId: EntityId;
@ -113,8 +113,14 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V
})
}
ngOnInit() {
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
ngOnChanges(changes: SimpleChanges) {
if (changes.entityName || changes.entityId) {
const entityNameChanges = changes.entityName;
const entityIdChanges = changes.entityId;
if ((entityNameChanges?.currentValue !== entityNameChanges?.previousValue) || (entityIdChanges?.currentValue !== entityIdChanges?.previousValue)) {
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
}
}
}
validate(): ValidationErrors | null {
@ -124,7 +130,9 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V
writeValue(config: CalculatedFieldGeofencingConfiguration): void {
this.geofencingConfiguration.patchValue(config, {emitEvent: false});
this.checkRelatedEntity(this.geofencingConfiguration.get('zoneGroups').value);
this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value);
if (this.geofencingConfiguration.enabled) {
this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value);
}
}
registerOnChange(fn: (config: CalculatedFieldGeofencingConfiguration) => void): void {

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

@ -110,7 +110,7 @@
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="!arguments.length"
[disabled]="!arguments.length || readonly"
(click)="onTestScript('filter')">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
@ -119,7 +119,7 @@
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript('filter')"
[disabled]="!arguments.length">
[disabled]="!arguments.length || readonly">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
@ -130,7 +130,10 @@
<ng-container formGroupName="input">
@if (!simpleMode) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div>
<div class="fixed-title-width"
tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.value-source-hint' | translate }}">
{{ 'calculated-fields.metrics.value-source' | translate }}
</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (inputType of AggInputTypes; track inputType) {
@ -170,7 +173,7 @@
[highlightRules]="highlightRules"
[editorCompleter]="editorCompleter"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
helpId="calculated-field/map_expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
@ -179,7 +182,7 @@
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="!arguments.length"
[disabled]="!arguments.length || readonly"
(click)="onTestScript('input.function')">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
@ -188,7 +191,7 @@
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript('input.function')"
[disabled]="!arguments.length">
[disabled]="!arguments.length || readonly">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
@ -210,12 +213,14 @@
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="saveMetric()"
[disabled]="metricForm.invalid || !metricForm.dirty">
{{ buttonTitle | translate }}
</button>
@if (!readonly) {
<button mat-raised-button
color="primary"
type="button"
(click)="saveMetric()"
[disabled]="metricForm.invalid || !metricForm.dirty">
{{ buttonTitle | translate }}
</button>
}
</div>
</div>

19
ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts

@ -24,6 +24,8 @@ import {
AggInputType,
AggInputTypeTranslations,
CalculatedFieldAggMetricValue,
calculatedFieldMetricFilterDefaultScript,
calculatedFieldMetricMapDefaultScript,
FORBIDDEN_NAMES,
forbiddenNamesValidator,
uniqueNameValidator
@ -42,7 +44,7 @@ interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricVal
@Component({
selector: 'tb-calculated-field-metrics-panel',
templateUrl: './calculated-field-metrics-panel.component.html',
styleUrl: '../common/calculated-field-panel.scss',
styleUrls: ['../common/calculated-field-panel.scss', '../../calculated-field.component.scss'],
})
export class CalculatedFieldMetricsPanelComponent implements OnInit {
@ -54,6 +56,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
@Input() editorCompleter: TbEditorCompleter;
@Input() highlightRules: AceHighlightRules;
@Input({required: true}) testScript: (expression?: string) => Observable<string>;
@Input() readonly = false;
metricDataApplied = output<CalculatedFieldAggMetricValue>();
filterExpanded = false;
@ -63,18 +66,18 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
function: [AggFunction.AVG],
allowFilter: [false],
filter: ['', Validators.required],
filter: [calculatedFieldMetricFilterDefaultScript, Validators.required],
input: this.fb.group({
type: [AggInputType.key],
key: ['', Validators.required],
function: ['', Validators.required],
function: [calculatedFieldMetricMapDefaultScript, Validators.required],
}),
defaultValue: [null]
});
entityFilter: EntityFilter;
readonly AggFunctions = Object.values(AggFunction) as AggFunction[];
AggFunctions = Object.values(AggFunction) as AggFunction[];
readonly AggFunctionTranslations = AggFunctionTranslations;
readonly ScriptLanguage = ScriptLanguage;
readonly AggInputType = AggInputType;
@ -103,6 +106,14 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
this.validateInputKey();
this.functionArgs = ['ctx', ...this.arguments];
if (this.simpleMode) {
this.AggFunctions = this.AggFunctions.filter(aggFunc => aggFunc !== AggFunction.COUNT_UNIQUE);
}
if (this.readonly) {
this.metricForm.disable({emitEvent: false});
}
}
saveMetric(): void {

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

@ -93,15 +93,26 @@
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let metric;">
<div class="tb-form-table-row-cell-buttons min-w-20">
<button type="button"
mat-icon-button
#button
(click)="manageMetrics($event, button, metric)"
[disabled]="disable"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
@if (disable) {
<button type="button"
mat-icon-button
#previewButton
(click)="manageMetrics($event, previewButton, metric, true)"
[matTooltip]="'calculated-fields.preview' | translate"
matTooltipPosition="above">
<mat-icon>visibility</mat-icon>
</button>
} @else {
<button type="button"
mat-icon-button
#button
(click)="manageMetrics($event, button, metric)"
[disabled]="disable"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
}
<button type="button"
mat-icon-button
(click)="onDelete($event, metric)"

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

@ -153,7 +153,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu
this.metricsFormArray.markAsDirty();
}
manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue): void {
manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue, readonly: boolean = false): void {
$event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide();
@ -173,7 +173,8 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu
editorCompleter: this.editorCompleter,
highlightRules: this.highlightRules,
simpleMode: this.simpleMode,
testScript: this.testScript
testScript: this.testScript,
readonly
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,
@ -218,7 +219,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu
}
writeValue(metrics: Record<string, CalculatedFieldAggMetric>): void {
this.metricsFormArray.clear();
this.metricsFormArray.clear({emitEvent: false});
this.populateZonesFormArray(metrics);
}
@ -230,7 +231,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu
};
this.metricsFormArray.push(this.fb.control(value), { emitEvent: false });
});
this.metricsFormArray.updateValueAndValidity();
this.updateDataSource(this.metricsFormArray.value);
}
private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string {

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

@ -16,7 +16,9 @@
-->
<div class="tb-form-panel" [formGroup]="outputForm">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.output-hint' | translate }}">
{{ 'calculated-fields.output' | translate }}
</div>
<div [class]=containerInputClass>
<div class="flex gap-3 xs:flex-col">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
@ -28,7 +30,7 @@
</mat-select>
</mat-form-field>
@if (outputForm.get('type').value === OutputType.Attribute
&& (entityId.entityType === EntityType.DEVICE || entityId.entityType === EntityType.DEVICE_PROFILE)) {
&& (entityId?.entityType === EntityType.DEVICE || entityId?.entityType === EntityType.DEVICE_PROFILE)) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.attribute-scope' | translate }}</mat-label>
<mat-select formControlName="scope" class="w-full">

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

@ -148,7 +148,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (change.currentValue !== change.previousValue) {
if (propName === 'simpleMode') {
if (propName === 'simpleMode' && !this.disabled) {
this.updatedFormWithMode();
if (!change.firstChange) {
this.outputForm.updateValueAndValidity();
@ -167,7 +167,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
if (value.type === OutputType.Timeseries && value.strategy?.type === OutputStrategyType.IMMEDIATE && value.strategy?.ttl) {
this.outputForm.get('strategy.useCustomTtl').setValue(true, {emitEvent: false});
}
this.outputForm.get('type').updateValueAndValidity({onlySelf: true});
this.outputForm.get('type').updateValueAndValidity({onlySelf: true, emitEvent: false});
}
registerOnChange(fn: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void): void {
@ -253,7 +253,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
private updateTimeSeriesTtl(value: boolean) {
if (value) {
this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: true});
this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: false});
} else {
this.outputForm.get('strategy.useCustomTtl').disable({emitEvent: false});
this.outputForm.get('strategy.ttl').disable({emitEvent: false});

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

@ -131,9 +131,11 @@ export class PropagationConfigurationComponent implements ControlValueAccessor,
writeValue(value: PropagationWithExpression): void {
value.expression = value.expression ?? calculatedFieldDefaultScript;
this.propagateConfiguration.patchValue(value, {emitEvent: false});
this.updatedFormWithScript();
if (!this.disabled) {
this.updatedFormWithScript();
}
setTimeout(() => {
this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true, emitEvent: false});
});
}

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

@ -63,10 +63,10 @@
<tb-time-unit-input required
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
labelText="{{ 'calculated-fields.deduplication-interval' | translate }}"
requiredText="{{ 'calculated-fields.deduplication-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.deduplication-interval-min' | translate: {sec: minAllowedDeduplicationIntervalInSecForCF} }}"
hintText="{{ 'calculated-fields.deduplication-interval-hint' | translate }}"
[minTime]="minAllowedDeduplicationIntervalInSecForCF"
formControlName="deduplicationIntervalInSec">
</tb-time-unit-input>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save