Browse Source

Merge branch 'rc' of github.com:thingsboard/thingsboard into release-4.3

pull/15304/head
Viacheslav Klimov 9 months ago
parent
commit
f7af9fdd3c
  1. 4
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 3
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  3. 4
      application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
  4. 10
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  5. 29
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/HasEntityLimit.java
  6. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  7. 8
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java
  8. 18
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java
  9. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java
  11. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java
  12. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java
  13. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java
  14. 5
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java
  15. 10
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java
  16. 84
      application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java
  17. 4
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java
  18. 8
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  19. 39
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java
  20. 34
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java
  21. 7
      common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java
  22. 49
      common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java
  23. 6
      common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.java
  24. 61
      common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java
  25. 42
      common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java
  26. 28
      dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java
  27. 14
      msa/edqs/docker/start-tb-edqs.sh
  28. 11
      ui-ngx/src/app/core/utils.ts
  29. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  30. 8
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss
  31. 8
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss
  32. 1
      ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html
  33. 2
      ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts
  34. 3
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts
  35. 3
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  36. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts
  37. 1
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts
  38. 1
      ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts
  39. 5
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts
  40. 9
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
  41. 14
      ui-ngx/src/app/shared/components/country-autocomplete.component.html
  42. 29
      ui-ngx/src/app/shared/components/country-autocomplete.component.scss
  43. 62
      ui-ngx/src/app/shared/components/country-autocomplete.component.ts
  44. 2
      ui-ngx/src/app/shared/components/time-unit-input.component.ts
  45. 4
      ui-ngx/src/app/shared/models/tenant.model.ts
  46. 4
      ui-ngx/src/assets/dashboard/api_usage.json
  47. 6
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -21,8 +21,8 @@ SET profile_data = jsonb_set(
profile_data,
'{configuration}',
jsonb_build_object(
'minAllowedScheduledUpdateIntervalInSecForCF', 60,
'maxRelationLevelPerCfArgument', 10,
'minAllowedScheduledUpdateIntervalInSecForCF', 10,
'maxRelationLevelPerCfArgument', 2,
'maxRelatedEntitiesToReturnPerCfArgument', 100,
'minAllowedDeduplicationIntervalInSecForCF', 10,
'minAllowedAggregationIntervalInSecForCF', 60,

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

@ -384,6 +384,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
EntityId mainId,
MultipleTbCallback parentCallback,
TriConsumer<EntityId, CalculatedFieldCtx, TbCallback> relationAction) {
if (isMyPartition(mainId, parentCallback)) {
return;
}
List<CalculatedFieldCtx> cfsByEntityIdAndProfile = getCalculatedFieldsByEntityIdAndProfile(mainId);
if (cfsByEntityIdAndProfile.isEmpty()) {
parentCallback.onSuccess();

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

@ -161,8 +161,8 @@ public class TenantProfileController extends BaseController {
" \"warnThreshold\": 0,\n" +
" \"maxCalculatedFieldsPerEntity\": 5,\n" +
" \"maxArgumentsPerCF\": 10,\n" +
" \"minAllowedScheduledUpdateIntervalInSecForCF\": 60,\n" +
" \"maxRelationLevelPerCfArgument\": 10,\n" +
" \"minAllowedScheduledUpdateIntervalInSecForCF\": 10,\n" +
" \"maxRelationLevelPerCfArgument\": 2,\n" +
" \"maxRelatedEntitiesToReturnPerCfArgument\": 100,\n" +
" \"maxDataPointsPerRollingArg\": 1000,\n" +
" \"maxStateSizeInKBytes\": 32,\n" +

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

@ -129,6 +129,8 @@ public class CalculatedFieldCtx implements Closeable {
private long cfCheckReevaluationIntervalMillis;
private long alarmReevaluationIntervalMillis;
private long maxRelatedEntitiesPerCfArgument;
private long minScheduledUpdateIntervalMillis;
private long minDeduplicationIntervalMillis;
private Argument propagationArgument;
private boolean applyExpressionForResolvedArguments;
@ -312,6 +314,8 @@ public class CalculatedFieldCtx implements Closeable {
this.cfCheckReevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(config.getCfReevaluationCheckInterval());
this.alarmReevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(config.getAlarmsReevaluationInterval());
this.maxRelatedEntitiesPerCfArgument = config.getMaxRelatedEntitiesToReturnPerCfArgument();
this.minScheduledUpdateIntervalMillis = TimeUnit.SECONDS.toMillis(config.getMinAllowedScheduledUpdateIntervalInSecForCF());
this.minDeduplicationIntervalMillis = TimeUnit.SECONDS.toMillis(config.getMinAllowedDeduplicationIntervalInSecForCF());
});
}
@ -763,9 +767,7 @@ public class CalculatedFieldCtx implements Closeable {
}
public boolean hasRelatedEntities() {
return CalculatedFieldType.GEOFENCING == cfType
|| CalculatedFieldType.PROPAGATION == cfType
|| CalculatedFieldType.RELATED_ENTITIES_AGGREGATION == cfType;
return cfHasRelationPathQuerySource;
}
public boolean shouldFetchRelatedEntities(CalculatedFieldState state) {
@ -781,7 +783,7 @@ public class CalculatedFieldCtx implements Closeable {
if (scheduledRefreshSupported.getLastScheduledRefreshTs() == DEFAULT_LAST_UPDATE_TS) {
return true;
}
return scheduledRefreshSupported.getLastScheduledRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis;
return scheduledRefreshSupported.getLastScheduledRefreshTs() < System.currentTimeMillis() - Math.max(scheduledUpdateIntervalMillis, minScheduledUpdateIntervalMillis);
}
@Override

29
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/HasEntityLimit.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state;
public interface HasEntityLimit {
default void checkEntityLimit(int currentEntitiesCount, CalculatedFieldCtx ctx) {
if (currentEntitiesCount >= ctx.getMaxRelatedEntitiesPerCfArgument()) {
throw new IllegalArgumentException(
"Exceeded the maximum allowed related entities per argument '"
+ ctx.getMaxRelatedEntitiesPerCfArgument() + "'. Increase the limit in the tenant profile configuration."
);
}
}
}

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

@ -22,13 +22,13 @@ import com.google.common.util.concurrent.ListenableFuture;
import lombok.EqualsAndHashCode;
import net.objecthunter.exp4j.Expression;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.common.util.NumberUtils;
import java.util.Map;
@ -52,7 +52,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
double expressionResult = ctx.evaluateSimpleExpression(expression.get(), this);
Output output = ctx.getOutput();
Object result = TbUtils.roundResult(expressionResult, output.getDecimalsByDefault());
Object result = NumberUtils.roundResult(expressionResult, output.getDecimalsByDefault());
JsonNode outputResult = createResultJson(ctx.isUseLatestTs(), output.getName(), result);
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder()

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

@ -183,7 +183,7 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
}
public void scheduleReevaluation() {
ScheduledFuture<?> future = ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx);
ScheduledFuture<?> future = ctx.scheduleReevaluation(getEnforcedDeduplicationIntervalMillis(), actorCtx);
if (future != null) {
reevaluationFuture = future;
}
@ -209,11 +209,15 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
}
private boolean shouldRecalculate() {
boolean intervalPassed = lastMetricsEvalTs <= System.currentTimeMillis() - deduplicationIntervalMs;
boolean intervalPassed = lastMetricsEvalTs <= System.currentTimeMillis() - getEnforcedDeduplicationIntervalMillis();
boolean argsUpdatedDuringInterval = lastArgsRefreshTs > lastMetricsEvalTs;
return intervalPassed && argsUpdatedDuringInterval;
}
private long getEnforcedDeduplicationIntervalMillis() {
return Math.max(deduplicationIntervalMs, ctx.getMinDeduplicationIntervalMillis());
}
private Map<EntityId, Map<String, ArgumentEntry>> prepareInputs() {
Map<EntityId, Map<String, ArgumentEntry>> inputs = new HashMap<>();
for (Map.Entry<String, ArgumentEntry> argEntry : arguments.entrySet()) {

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

@ -24,6 +24,7 @@ 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.HasEntityLimit;
import org.thingsboard.server.service.cf.ctx.state.HasLatestTs;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
@ -34,7 +35,7 @@ import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldSta
@Data
@AllArgsConstructor
public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs {
public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs, HasEntityLimit {
private final Map<EntityId, ArgumentEntry> entityInputs;
@ -66,18 +67,18 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs
@Override
public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {
checkRelatedEntitiesNumber(ctx);
checkEntityLimit(entityInputs.size(), ctx);
entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs);
} else if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
if (entry.isForceResetPrevious()) {
checkRelatedEntitiesNumber(ctx);
checkEntityLimit(entityInputs.size(), ctx);
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry);
} else {
ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId());
if (argumentEntry != null) {
argumentEntry.updateEntry(singleValueArgumentEntry, ctx);
} else {
checkRelatedEntitiesNumber(ctx);
checkEntityLimit(entityInputs.size(), ctx);
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry);
}
}
@ -87,15 +88,6 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs
return true;
}
private void checkRelatedEntitiesNumber(CalculatedFieldCtx ctx) {
if (entityInputs.size() >= ctx.getMaxRelatedEntitiesPerCfArgument()) {
throw new IllegalArgumentException(
"Exceeded the maximum allowed related entities per argument '"
+ ctx.getMaxRelatedEntitiesPerCfArgument() + "'. Increase the limit in the tenant profile configuration."
);
}
}
@Override
public boolean isEmpty() {
return entityInputs.isEmpty();

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.java

@ -15,8 +15,8 @@
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.common.util.NumberUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ -37,7 +37,7 @@ public class AvgAggEntry extends BaseAggEntry {
@Override
protected Object prepareResult(Integer precision) {
double result = sum.divide(BigDecimal.valueOf(count), RoundingMode.HALF_UP).doubleValue();
return TbUtils.roundResult(result, precision);
return NumberUtils.roundResult(result, precision);
}
@Override

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java

@ -15,8 +15,8 @@
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.common.util.NumberUtils;
public class MaxAggEntry extends BaseAggEntry {
@ -31,7 +31,7 @@ public class MaxAggEntry extends BaseAggEntry {
@Override
protected Object prepareResult(Integer precision) {
return TbUtils.roundResult(max, precision);
return NumberUtils.roundResult(max, precision);
}
@Override

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java

@ -15,8 +15,8 @@
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.common.util.NumberUtils;
public class MinAggEntry extends BaseAggEntry {
@ -31,7 +31,7 @@ public class MinAggEntry extends BaseAggEntry {
@Override
protected Object prepareResult(Integer precision) {
return TbUtils.roundResult(min, precision);
return NumberUtils.roundResult(min, precision);
}
@Override

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java

@ -15,8 +15,8 @@
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.common.util.NumberUtils;
import java.math.BigDecimal;
@ -33,7 +33,7 @@ public class SumAggEntry extends BaseAggEntry {
@Override
protected Object prepareResult(Integer precision) {
return TbUtils.roundResult(sum.doubleValue(), precision);
return NumberUtils.roundResult(sum.doubleValue(), precision);
}
@Override

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

@ -23,7 +23,6 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.common.util.DebugModeUtil;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@ -41,6 +40,7 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.common.util.NumberUtils;
import java.time.Instant;
import java.time.ZoneId;
@ -291,7 +291,7 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
ArgumentEntry argumentEntry = entry.getValue();
if (!argumentEntry.isEmpty()) {
Object resultValue = argumentEntry.getValue() instanceof Number number
? TbUtils.roundResult(number.doubleValue(), precision)
? NumberUtils.roundResult(number.doubleValue(), precision)
: argumentEntry.getValue();
metricsNode.put(metricName, JacksonUtil.toString(resultValue));
}

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

@ -51,9 +51,10 @@ public class GeofencingZoneState {
this.ts = attributeKvEntry.getLastUpdateTs();
this.version = attributeKvEntry.getVersion();
if (entry.getValueAsString() == null) {
throw new IllegalArgumentException("Perimeter attribute key '" + entry.getKey() + "' not found for Zone with id: " + zoneId);
throw new IllegalArgumentException("Perimeter attribute '" + entry.getKey() + "' not found for Zone with id: " + zoneId);
}
this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class);
this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class,
"Invalid perimeter definition format for Zone with id: " + zoneId + ". Failed to parse attribute '" + entry.getKey() + "'");
}
public GeofencingZoneState(GeofencingZoneProto proto) {

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

@ -22,6 +22,7 @@ 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.HasEntityLimit;
import java.util.ArrayList;
import java.util.Collection;
@ -30,7 +31,7 @@ import java.util.List;
import java.util.Set;
@Data
public class PropagationArgumentEntry implements ArgumentEntry {
public class PropagationArgumentEntry implements ArgumentEntry, HasEntityLimit {
private Set<EntityId> entityIds;
private transient List<EntityId> added;
@ -93,12 +94,7 @@ public class PropagationArgumentEntry implements ArgumentEntry {
private boolean checkAdded(Collection<EntityId> updatedIds, CalculatedFieldCtx ctx) {
for (EntityId id : updatedIds) {
if (entityIds.size() >= ctx.getMaxRelatedEntitiesPerCfArgument()) {
throw new IllegalArgumentException(
"Exceeded the maximum allowed related entities per argument '"
+ ctx.getMaxRelatedEntitiesPerCfArgument() + "'. Increase the limit in the tenant profile configuration."
);
}
checkEntityLimit(entityIds.size(), ctx);
if (entityIds.add(id)) {
if (added == null) {
added = new ArrayList<>();

84
application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.msg.queue.TbCallback;
@ -40,6 +41,7 @@ import java.lang.reflect.Field;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@ -47,7 +49,10 @@ import static java.time.ZoneOffset.UTC;
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoUnit.MONTHS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.Assert.assertEquals;
import static org.thingsboard.server.common.data.ApiFeature.SMS;
import static org.thingsboard.server.common.data.ApiUsageRecordKey.SMS_EXEC_COUNT;
@DaoSqlTest
public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
@ -157,6 +162,85 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
assertThat(tenantApiUsageState.getNextCycleTs()).isEqualTo(firstOfNextMonth);
}
@Test
public void checkTenantCreatedWithSmsDisabledApiUsage() throws Exception {
TenantProfile tenantProfile = new TenantProfile();
tenantProfile.setName("Test profile");
TenantProfileData tenantProfileData = new TenantProfileData();
DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder()
.smsEnabled(false)
.build();
tenantProfileData.setConfiguration(config);
tenantProfile.setProfileData(tenantProfileData);
tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class);
Tenant tenant = new Tenant();
tenant.setTitle("My test tenant");
tenant.setTenantProfileId(tenantProfile.getId());
tenant = saveTenant(tenant);
ApiUsageState apiUsageState = apiUsageStateService.findApiUsageStateByEntityId(tenant.getId());
assertThat(apiUsageState.getSmsExecState()).isEqualTo(ApiUsageStateValue.DISABLED);
TenantId finalTenantId = tenant.getId();
ApiUsageStateId finalApiUsageStateId = apiUsageState.getId();
await().atMost(5, TimeUnit.SECONDS).until(() -> {
Optional<TsKvEntry> smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get();
return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.DISABLED.name());
});
await().atMost(5, TimeUnit.SECONDS).until(() -> {
Optional<TsKvEntry> smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get();
return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(0L);
});
// enable SMS and check that the ApiUsageState is updated accordingly
config = DefaultTenantProfileConfiguration.builder()
.smsEnabled(true)
.maxSms(10)
.build();
tenantProfileData.setConfiguration(config);
tenantProfile.setProfileData(tenantProfileData);
doPost("/api/tenantProfile", tenantProfile, TenantProfile.class);
await().atMost(5, TimeUnit.SECONDS).until(() ->
apiUsageStateService.findApiUsageStateByEntityId(finalTenantId).getSmsExecState() == ApiUsageStateValue.ENABLED);
await().atMost(5, TimeUnit.SECONDS).until(() -> {
Optional<TsKvEntry> smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get();
return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.ENABLED.name());
});
await().atMost(5, TimeUnit.SECONDS).until(() -> {
Optional<TsKvEntry> smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get();
return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(10L);
});
//disable SMS and check that the ApiUsageState is updated accordingly
config = DefaultTenantProfileConfiguration.builder()
.smsEnabled(false)
.build();
tenantProfileData.setConfiguration(config);
tenantProfile.setProfileData(tenantProfileData);
doPost("/api/tenantProfile", tenantProfile, TenantProfile.class);
await().atMost(5, TimeUnit.SECONDS).until(() ->
apiUsageStateService.findApiUsageStateByEntityId(finalTenantId).getSmsExecState() == ApiUsageStateValue.DISABLED);
await().atMost(5, TimeUnit.SECONDS).until(() -> {
Optional<TsKvEntry> smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get();
return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.DISABLED.name());
});
await().atMost(5, TimeUnit.SECONDS).until(() -> {
Optional<TsKvEntry> smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get();
return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(0L);
});
}
private TenantProfile createTenantProfile() {
TenantProfile tenantProfile = new TenantProfile();
tenantProfile.setName("Tenant Profile");

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

@ -177,7 +177,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new StringDataEntry("zone", "someString"), 363L, 155L);
assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)))
.isExactlyInstanceOf(IllegalArgumentException.class)
.hasMessage("The given string value cannot be transformed to Json object: someString");
.hasMessage("Invalid perimeter definition format for Zone with id: " + ZONE_1_ID + ". Failed to parse attribute 'zone'");
}
@Test
@ -185,7 +185,7 @@ public class GeofencingValueArgumentEntryTest {
BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new JsonDataEntry("zone", "\"{}\""), 363L, 155L);
assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)))
.isExactlyInstanceOf(IllegalArgumentException.class)
.hasMessage("The given string value cannot be transformed to Json object: \"{}\"");
.hasMessage("Invalid perimeter definition format for Zone with id: " + ZONE_1_ID + ". Failed to parse attribute 'zone'");
}
}

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

@ -172,12 +172,12 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxCalculatedFieldsPerEntity = 5;
@Schema(example = "10")
private long maxArgumentsPerCF = 10;
@Schema(example = "60")
private int minAllowedScheduledUpdateIntervalInSecForCF = 60;
@Builder.Default
@Schema(example = "10")
private int minAllowedScheduledUpdateIntervalInSecForCF = 10;
@Builder.Default
@Schema(example = "2")
@Positive
private int maxRelationLevelPerCfArgument = 10;
private int maxRelationLevelPerCfArgument = 2;
@Builder.Default
@Schema(example = "100")
@Positive

39
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java

@ -26,6 +26,7 @@ import org.mvel2.execution.ExecutionHashMap;
import org.mvel2.execution.ExecutionLinkedHashSet;
import org.mvel2.util.MethodStub;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.NumberUtils;
import org.thingsboard.common.util.geo.Coordinates;
import org.thingsboard.common.util.geo.GeoUtil;
import org.thingsboard.common.util.geo.RangeUnit;
@ -35,7 +36,6 @@ import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
@ -258,15 +258,13 @@ public class TbUtils {
byte[].class, int.class, int.class)));
parserConfig.addImport("parseBytesLongToDouble", new MethodStub(TbUtils.class.getMethod("parseBytesLongToDouble",
byte[].class, int.class, int.class, boolean.class)));
parserConfig.addImport("toFixed", new MethodStub(TbUtils.class.getMethod("toFixed",
parserConfig.addImport("toFixed", new MethodStub(NumberUtils.class.getMethod("toFixed",
double.class, int.class)));
parserConfig.addImport("toFixed", new MethodStub(TbUtils.class.getMethod("toFixed",
parserConfig.addImport("toFixed", new MethodStub(NumberUtils.class.getMethod("toFixed",
float.class, int.class)));
parserConfig.addImport("toInt", new MethodStub(TbUtils.class.getMethod("toInt",
parserConfig.addImport("toInt", new MethodStub(NumberUtils.class.getMethod("toInt",
double.class)));
parserConfig.addImport("roundResult", new MethodStub(TbUtils.class.getMethod("roundResult",
double.class, Integer.class)));
parserConfig.addImport("isNaN", new MethodStub(TbUtils.class.getMethod("isNaN",
parserConfig.addImport("isNaN", new MethodStub(NumberUtils.class.getMethod("isNaN",
double.class)));
parserConfig.addImport("hexToBytes", new MethodStub(TbUtils.class.getMethod("hexToBytes",
ExecutionContext.class, String.class)));
@ -1176,32 +1174,6 @@ public class TbUtils {
return new String(hexChars, StandardCharsets.UTF_8);
}
public static double toFixed(double value, int precision) {
return BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP).doubleValue();
}
public static float toFixed(float value, int precision) {
return BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP).floatValue();
}
public static int toInt(double value) {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue();
}
public static Object roundResult(double value, Integer precision) {
if (precision == null) {
return value;
}
if (precision.equals(0)) {
return toInt(value);
}
return toFixed(value, precision);
}
public static boolean isNaN(double value) {
return Double.isNaN(value);
}
public static ExecutionHashMap<String, Object> toFlatMap(ExecutionContext ctx, Map<String, Object> json) {
return toFlatMap(ctx, json, new ArrayList<>(), true);
}
@ -1220,7 +1192,6 @@ public class TbUtils {
return map;
}
public static String encodeURI(String uri) {
String encoded = URLEncoder.encode(uri, StandardCharsets.UTF_8);
for (var entry : mdnEncodingReplacements.entrySet()) {

34
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java

@ -279,13 +279,6 @@ public class TbUtilsTest {
Assertions.assertEquals(0, Float.compare(floatVal, TbUtils.parseLittleEndianHexToFloat(floatValHexRev)));
}
@Test
public void toFixedFloat() {
float actualF = TbUtils.toFixed(floatVal, 3);
Assertions.assertEquals(1, Float.compare(floatVal, actualF));
Assertions.assertEquals(0, Float.compare(29.298f, actualF));
}
@Test
public void parseBytesToFloat() {
byte[] floatValByte = {0x0A};
@ -461,13 +454,6 @@ public class TbUtilsTest {
Assertions.assertEquals(0, Double.compare(doubleVal, TbUtils.parseLittleEndianHexToDouble(longValHexRev)));
}
@Test
public void toFixedDouble() {
double actualD = TbUtils.toFixed(doubleVal, 3);
Assertions.assertEquals(-1, Double.compare(doubleVal, actualD));
Assertions.assertEquals(0, Double.compare(1729.173, actualD));
}
@Test
public void parseBytesToDouble() {
byte[] doubleValByte = {0x0A};
@ -1147,26 +1133,6 @@ public class TbUtilsTest {
Assertions.assertEquals(expected, actual);
}
@Test
public void toInt() {
Assertions.assertEquals(1729, TbUtils.toInt(doubleVal));
Assertions.assertEquals(13, TbUtils.toInt(12.8));
Assertions.assertEquals(28, TbUtils.toInt(28.0));
}
@Test
public void roundResult() {
Assertions.assertEquals(1729.1729, TbUtils.roundResult(doubleVal, null));
Assertions.assertEquals(1729, TbUtils.roundResult(doubleVal, 0));
Assertions.assertEquals(1729.17, TbUtils.roundResult(doubleVal, 2));
}
@Test
public void isNaN() {
assertFalse(TbUtils.isNaN(doubleVal));
assertTrue(TbUtils.isNaN(Double.NaN));
}
@Test
public void isInsidePolygon() {
// outside the polygon

7
common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java

@ -115,10 +115,15 @@ public class JacksonUtil {
@Contract("null, _ -> null") // so that IDE doesn't show NPE warning when input is not null
public static <T> T fromString(String string, Class<T> clazz) {
return fromString(string, clazz, "The given string value cannot be transformed to Json object: " + string);
}
@Contract("null, _, _ -> null") // so that IDE doesn't show NPE warning when input is not null
public static <T> T fromString(String string, Class<T> clazz, String errorMsg) {
try {
return string != null ? OBJECT_MAPPER.readValue(string, clazz) : null;
} catch (IOException e) {
throw new IllegalArgumentException("The given string value cannot be transformed to Json object: " + string, e);
throw new IllegalArgumentException(errorMsg, e);
}
}

49
common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.common.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class NumberUtils {
public static boolean isNaN(double value) {
return Double.isNaN(value);
}
public static double toFixed(double value, int precision) {
return BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP).doubleValue();
}
public static float toFixed(float value, int precision) {
return BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP).floatValue();
}
public static int toInt(double value) {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue();
}
public static Object roundResult(double value, Integer precision) {
if (precision == null) {
return value;
}
if (precision.equals(0)) {
return toInt(value);
}
return toFixed(value, precision);
}
}

6
common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.java

@ -19,6 +19,7 @@ import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -32,6 +33,9 @@ public class PerimeterDefinitionDeserializer extends JsonDeserializer<PerimeterD
JsonNode node = codec.readTree(p);
if (node.isObject()) {
if (!node.has("latitude") || !node.has("longitude") || !node.has("radius")) {
throw JsonMappingException.from(p, "CirclePerimeterDefinition missing required fields. Received: " + node);
}
double latitude = node.get("latitude").asDouble();
double longitude = node.get("longitude").asDouble();
double radius = node.get("radius").asDouble();
@ -42,7 +46,7 @@ public class PerimeterDefinitionDeserializer extends JsonDeserializer<PerimeterD
String polygonStrDefinition = mapper.writeValueAsString(node);
return new PolygonPerimeterDefinition(polygonStrDefinition);
}
throw new IOException("Failed to deserialize PerimeterDefinition from node: " + node);
throw JsonMappingException.from(p, "Unknown JSON format for PerimeterDefinition. Expected OBJECT (Circle) or ARRAY (Polygon), but found: " + node.getNodeType());
}
}

61
common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java

@ -0,0 +1,61 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.common.util;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class NumberUtilsTest {
private final Float floatVal = 29.29824f;
private final double doubleVal = 1729.1729;
@Test
public void isNaN() {
assertThat(NumberUtils.isNaN(doubleVal)).isFalse();
assertThat(NumberUtils.isNaN(Double.NaN)).isTrue();
}
@Test
public void toFixedFloat() {
float actualF = NumberUtils.toFixed(floatVal, 3);
assertThat(Float.compare(floatVal, actualF)).isEqualTo(1);
assertThat(Float.compare(29.298f, actualF)).isEqualTo(0);
}
@Test
public void toFixedDouble() {
double actualD = NumberUtils.toFixed(doubleVal, 3);
assertThat(Double.compare(doubleVal, actualD)).isEqualTo(-1);
assertThat(Double.compare(1729.173, actualD)).isEqualTo(0);
}
@Test
public void toInt() {
assertThat(NumberUtils.toInt(doubleVal)).isEqualTo(1729);
assertThat(NumberUtils.toInt(12.8)).isEqualTo(13);
assertThat(NumberUtils.toInt(28.0)).isEqualTo(28);
}
@Test
public void roundResult() {
assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729);
assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729);
assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17);
}
}

42
common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java

@ -15,10 +15,12 @@
*/
package org.thingsboard.common.util.geo;
import com.fasterxml.jackson.databind.JsonMappingException;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.JacksonUtil;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class PerimeterDefinitionDeserializerTest {
@ -47,4 +49,44 @@ public class PerimeterDefinitionDeserializerTest {
PolygonPerimeterDefinition poly = (PolygonPerimeterDefinition) def;
assertThat(poly.getPolygonDefinition()).isEqualTo(json);
}
@Test
void shouldThrowWhenCircleFieldIsMissing() {
// Missing "radius"
String badJson = """
{
"latitude": 48.8566,
"longitude": 2.3522
}
""";
String customError = "Custom error context";
assertThatThrownBy(() -> JacksonUtil.fromString(badJson, PerimeterDefinition.class, customError))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(customError)
.hasCauseInstanceOf(JsonMappingException.class)
.rootCause()
.hasMessageContaining("CirclePerimeterDefinition missing required fields");
}
@Test
void shouldThrowWhenJsonIsGarbage() {
String garbageJson = "\"NotAnObjectOrArray\"";
String customError = "Garbage check";
assertThatThrownBy(() -> JacksonUtil.fromString(garbageJson, PerimeterDefinition.class, customError))
.isInstanceOf(IllegalArgumentException.class)
.hasCauseInstanceOf(JsonMappingException.class)
.rootCause()
.hasMessageContaining("Unknown JSON format");
}
@Test
void shouldReturnNullWhenInputIsNull() {
//noinspection ConstantConditions
PerimeterDefinition result = JacksonUtil.fromString(null, PerimeterDefinition.class, "Error");
assertThat(result).isNull();
}
}

28
dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java

@ -35,7 +35,7 @@ import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.entity.AbstractEntityService;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
@ -101,6 +101,21 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A
entityId = Objects.requireNonNullElse(entityId, tenantId);
log.trace("Executing createDefaultUsageRecord [{}]", entityId);
validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
ApiUsageStateValue smsApiUsageState = ApiUsageStateValue.ENABLED;
DefaultTenantProfileConfiguration configuration = null;
if (entityId.getEntityType() == EntityType.TENANT && !entityId.equals(TenantId.SYS_TENANT_ID)) {
tenantId = (TenantId) entityId;
Tenant tenant = tenantService.findTenantById(tenantId);
TenantProfile tenantProfile = tenantProfileDao.findById(tenantId, tenant.getTenantProfileId().getId());
configuration = (DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration();
if (configuration.getSmsEnabled() != null && !configuration.getSmsEnabled()) {
smsApiUsageState = ApiUsageStateValue.DISABLED;
}
}
ApiUsageState apiUsageState = new ApiUsageState();
apiUsageState.setTenantId(tenantId);
apiUsageState.setEntityId(entityId);
@ -109,7 +124,7 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A
apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setTbelExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED);
apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setSmsExecState(smsApiUsageState);
apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setAlarmExecState(ApiUsageStateValue.ENABLED);
apiUsageStateValidator.validate(apiUsageState, ApiUsageState::getTenantId);
@ -138,17 +153,12 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A
apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(),
new StringDataEntry(ApiFeature.EMAIL.getApiStateKey(), ApiUsageStateValue.ENABLED.name())));
apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(),
new StringDataEntry(ApiFeature.SMS.getApiStateKey(), ApiUsageStateValue.ENABLED.name())));
new StringDataEntry(ApiFeature.SMS.getApiStateKey(), smsApiUsageState.name())));
apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(),
new StringDataEntry(ApiFeature.ALARM.getApiStateKey(), ApiUsageStateValue.ENABLED.name())));
tsService.save(tenantId, saved.getId(), apiUsageStates, 0L);
if (entityId.getEntityType() == EntityType.TENANT && !entityId.equals(TenantId.SYS_TENANT_ID)) {
tenantId = (TenantId) entityId;
Tenant tenant = tenantService.findTenantById(tenantId);
TenantProfile tenantProfile = tenantProfileDao.findById(tenantId, tenant.getTenantProfileId().getId());
TenantProfileConfiguration configuration = tenantProfile.getProfileData().getConfiguration();
if (configuration != null) {
List<TsKvEntry> profileThresholds = new ArrayList<>();
for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) {
if (key.getApiLimitKey() == null) continue;

14
msa/edqs/docker/start-tb-edqs.sh

@ -15,11 +15,19 @@
# limitations under the License.
#
CONF_FOLDER=${pkg.installFolder}/conf
jarfile=${pkg.installFolder}/bin/${pkg.name}.jar
configfile=${pkg.name}.conf
source "${CONF_FOLDER}/${configfile}"
CONF_FOLDER="/config"
if [ -d "${CONF_FOLDER}" ]; then
LOGGING_CONFIG="${CONF_FOLDER}/logback.xml"
source "${CONF_FOLDER}/${configfile}"
export LOADER_PATH=${CONF_FOLDER},${LOADER_PATH}
else
CONF_FOLDER="/usr/share/${pkg.name}/conf"
LOGGING_CONFIG="/usr/share/${pkg.name}/conf/logback.xml"
source "${CONF_FOLDER}/${configfile}"
fi
echo "Starting '${project.name}' ..."
@ -27,5 +35,5 @@ cd ${pkg.installFolder}/bin
exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.edqs.ThingsboardEdqsApplication \
-Dspring.jpa.hibernate.ddl-auto=none \
-Dlogging.config=$CONF_FOLDER/logback.xml \
-Dlogging.config=${LOGGING_CONFIG} \
org.springframework.boot.loader.launch.PropertiesLauncher

11
ui-ngx/src/app/core/utils.ts

@ -34,7 +34,7 @@ import {
} from '@shared/models/js-function.models';
import { DomSanitizer } from '@angular/platform-browser';
import { SecurityContext } from '@angular/core';
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
const varsRegex = /\${([^}]*)}/g;
const emailRegex = /^[A-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
@ -1084,3 +1084,12 @@ export const validateEmail = (control: AbstractControl): ValidationErrors | null
return emailRegex.test(control.value) ? null : {email: true};
};
export const objectRequired = (): ValidatorFn => {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (value && !isObject(value)) {
return { objectRequired: true };
}
return null;
};
}

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

@ -109,7 +109,9 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
}
this.tableTitle = this.pageMode ? '' : this.translate.instant('alarm-rule.alarm-rules');
this.detailsPanelEnabled = this.pageMode;
this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD);
this.entityResources = {
helpLinkId: 'alarmRules'
};
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = {
type: 'alarm-rule.alarm-rule',

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

@ -13,6 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../../../../../../scss/constants";
:host {
.filter-title {
padding: 12px 0;
@ -45,11 +47,11 @@
&-label {
font-size: 15px;
font-weight: 400;
color: #00695C;
color: $tb-primary-color;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba(#00695C, 0.32);
background-color: rgba(#00695C, 0.04);
border: 1px solid rgba($tb-primary-color, 0.32);
background-color: rgba($tb-primary-color, 0.04);
}
}

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

@ -13,6 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../../../../../../scss/constants";
:host {
.filter-title {
padding: 12px 8px;
@ -40,11 +42,11 @@
&-label {
font-size: 15px;
font-weight: 400;
color: #00695C;
color: $tb-primary-color;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba(#00695C, 0.32);
background-color: rgba(#00695C, 0.04);
border: 1px solid rgba($tb-primary-color, 0.32);
background-color: rgba($tb-primary-color, 0.04);
}
}
}

1
ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html

@ -45,6 +45,7 @@
</mat-slide-toggle>
<section class="flex gap-3">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="flex-1">
<mat-label translate>api-key.expiration-date</mat-label>
<mat-select formControlName="expirationTime" aria-label="Expiration date selector" (selectionChange)="onExpirationDateChange()">
@for (value of expirationTimeOptions; track value) {
<mat-option [value]="value">

2
ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts

@ -81,7 +81,7 @@ export class ApiKeysTableConfig extends EntityTableConfig<ApiKeyInfo> {
new DateEntityTableColumn<ApiKeyInfo>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<ApiKeyInfo>('description', 'api-key.description', '100%',
(entity) => this.customTranslate.transform(entity?.description), () => ({}), true, () => ({}),
(entity) => entity?.description.length > 80 ? this.customTranslate.transform(entity.description) : undefined, false,
(entity) => entity?.description.length > 40 ? this.customTranslate.transform(entity.description) : undefined, false,
{
name: this.translate.instant('api-key.edit-description'),
icon: 'edit',

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

@ -124,13 +124,14 @@ export class CalculatedFieldComponent extends EntityComponent<CalculatedFieldsTa
this.entityForm.patchValue({ type }, {emitEvent: false, onlySelf: true});
setTimeout(() => {
this.entityForm.patchValue({ configuration: preparedConfig, debugSettings, entityId, ...value }, {emitEvent: false});
this.entityForm.get('type').updateValueAndValidity();
});
}
onTestScript(expression?: string): Observable<string> {
return this.cfFormService.testScript(
this.entity?.id?.id,
this.entityFormValue(),
this.entityValue,
this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig),
this.destroyRef,
expression

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

@ -186,6 +186,9 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
this.toggleScopeByOutputType(this.outputForm.get('type').value);
this.updatedStrategy();
this.updateTimeSeriesTtl(this.outputForm.get('strategy.saveTimeSeries').value);
if (this.outputForm.invalid) {
this.outputForm.updateValueAndValidity();
}
}
}

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

@ -143,7 +143,7 @@ export class PropagationConfigurationComponent implements ControlValueAccessor,
this.updatedFormWithScript();
}
setTimeout(() => {
this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true, emitEvent: false});
this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}

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

@ -155,6 +155,7 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
this.simpleConfiguration.patchValue(formValue, {emitEvent: false});
setTimeout(() => {
this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
this.simpleConfiguration.get('output').updateValueAndValidity({onlySelf: true});
});
}

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

@ -218,6 +218,7 @@ export class TenantProfileAutocompleteComponent implements ControlValueAccessor,
createTenantProfile($event: Event, profileName: string) {
$event.preventDefault();
$event.stopPropagation();
const tenantProfile: TenantProfile = {
name: profileName
};

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

@ -17,7 +17,7 @@
import { Component, forwardRef, Input } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { DefaultTenantProfileConfiguration, FormControlsFrom } from '@shared/models/tenant.model';
import { isDefinedAndNotNull } from '@core/utils';
import { isDefinedAndNotNull, isUndefinedOrNull} from '@core/utils';
import { RateLimitsType } from './rate-limits/rate-limits.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { coerceBoolean } from '@shared/decorators/coercion';
@ -170,6 +170,9 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA
writeValue(value: DefaultTenantProfileConfiguration | null): void {
if (isDefinedAndNotNull(value)) {
if (isUndefinedOrNull(value.smsEnabled)) {
value.smsEnabled = true;
}
this.maxSmsValidation(value.smsEnabled);
this.tenantProfileConfigurationForm.patchValue(value, {emitEvent: false});
}

9
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts

@ -83,6 +83,11 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
const providers = setting.providers.filter(provider => provider.enable);
providers.forEach(provider => delete provider.enable);
const enforcedUsersFilter = this.twoFaFormGroup.get('enforcedUsersFilter').value;
if (enforcedUsersFilter.filterByTenants) {
enforcedUsersFilter.tenantProfilesIds = null;
} else {
enforcedUsersFilter.tenantsIds = null;
}
delete enforcedUsersFilter.filterByTenants;
const config = Object.assign(setting, {providers}, {enforcedUsersFilter});
this.filterByTenants = this.twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').value;
@ -142,7 +147,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
verificationCodeCheckRateLimitEnable: [false],
verificationCodeCheckRateLimitNumber: [{value: 3, disabled: true}, this.posIntValidation],
verificationCodeCheckRateLimitTime: [{value: 900, disabled: true}, this.posIntValidation],
minVerificationCodeSendPeriod: ['30', [Validators.required, Validators.min(5), Validators.pattern(/^\d*$/)]],
minVerificationCodeSendPeriod: [30, [Validators.required, Validators.min(5), Validators.pattern(/^\d*$/)]],
providers: this.fb.array([])
});
Object.values(TwoFactorAuthProviderType).forEach(provider => {
@ -216,7 +221,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
}
});
this.twoFaFormGroup.patchValue(processFormValue);
this.filterByTenants = isDefined(this.filterByTenants) ? this.filterByTenants : !Array.isArray(settings?.enforcedUsersFilter.tenantProfilesIds);
this.filterByTenants = isDefined(this.filterByTenants) ? this.filterByTenants : !Array.isArray(settings?.enforcedUsersFilter?.tenantProfilesIds);
this.twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').patchValue(this.filterByTenants, {onlySelf: true});
}

14
ui-ngx/src/app/shared/components/country-autocomplete.component.html

@ -15,14 +15,19 @@
limitations under the License.
-->
<mat-form-field [formGroup]="countryFormGroup" class="mat-block" subscriptSizing="{{subscriptSizing}}" [appearance]="appearance">
<mat-form-field [formGroup]="countryFormGroup" class="mat-block" [subscriptSizing]="subscriptSizing" [appearance]="appearance">
<mat-label>{{ labelText }}</mat-label>
@if (countryFormGroup.get('country').value?.flag) {
<span matPrefix>
{{ countryFormGroup.get('country').value.flag }}
</span>
}
<input matInput type="text"
#countryInput
#autocompleteTrigger="matAutocompleteTrigger"
formControlName="country"
(focusin)="onFocus()"
(blur)="onTouched()"
[required]="required"
(blur)="checkInputAndAutoSelect()"
[matAutocomplete]="countryAutocomplete">
<button *ngIf="countryFormGroup.get('country').value && !disabled"
type="button"
@ -55,4 +60,7 @@
<mat-error *ngIf="countryFormGroup.get('country').hasError('required')">
{{ requiredText }}
</mat-error>
<mat-error *ngIf="countryFormGroup.get('country').hasError('objectRequired')">
{{ objectRequiredText }}
</mat-error>
</mat-form-field>

29
ui-ngx/src/app/shared/components/country-autocomplete.component.scss

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host ::ng-deep {
.mat-form-field-appearance-fill {
.mat-mdc-form-field-icon-prefix {
align-self: baseline;
}
}
.mat-mdc-form-field-icon-prefix {
& > * {
color: initial;
padding-left: 8px;
}
}
}

62
ui-ngx/src/app/shared/components/country-autocomplete.component.ts

@ -14,7 +14,18 @@
/// limitations under the License.
///
import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import { Country, CountryData } from '@shared/models/country.models';
import {
ControlValueAccessor,
@ -23,14 +34,16 @@ import {
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator
Validator,
Validators
} from '@angular/forms';
import { isNotEmptyStr } from '@core/utils';
import { isNotEmptyStr, objectRequired } from '@core/utils';
import { Observable, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators';
import { SubscriptSizing, MatFormFieldAppearance } from '@angular/material/form-field';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { coerceBoolean } from '@shared/decorators/coercion';
import { TranslateService } from '@ngx-translate/core';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
interface CountrySearchData extends Country {
searchText?: string;
@ -39,6 +52,7 @@ interface CountrySearchData extends Country {
@Component({
selector: 'tb-country-autocomplete',
templateUrl: 'country-autocomplete.component.html',
styleUrls: ['./country-autocomplete.component.scss'],
providers: [
CountryData,
{
@ -53,7 +67,7 @@ interface CountrySearchData extends Country {
}
]
})
export class CountryAutocompleteComponent implements OnInit, ControlValueAccessor, Validator {
export class CountryAutocompleteComponent implements OnInit, OnChanges, ControlValueAccessor, Validator {
@Input()
labelText = this.translate.instant('contact.country');
@ -61,6 +75,9 @@ export class CountryAutocompleteComponent implements OnInit, ControlValueAccesso
@Input()
requiredText = this.translate.instant('contact.country-required');
@Input()
objectRequiredText = this.translate.instant('contact.country-object-required');
@Input()
autocompleteHint: string;
@ -79,6 +96,8 @@ export class CountryAutocompleteComponent implements OnInit, ControlValueAccesso
@ViewChild('countryInput', {static: true}) countryInput: ElementRef;
@ViewChild('autocompleteTrigger') autocompleteTrigger: MatAutocompleteTrigger;
@Output()
selectCountryCode = new EventEmitter<string>();
@ -103,10 +122,24 @@ export class CountryAutocompleteComponent implements OnInit, ControlValueAccesso
private countryData: CountryData,
private translate: TranslateService) {
this.countryFormGroup = this.fb.group({
country: ['']
country: ['', objectRequired()]
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.required) {
const requiredChanges = changes.required;
if (requiredChanges.currentValue !== requiredChanges.previousValue) {
if (requiredChanges.currentValue) {
this.countryFormGroup.get('country').addValidators(Validators.required);
} else {
this.countryFormGroup.get('country').removeValidators(Validators.required);
}
this.countryFormGroup.get('country').updateValueAndValidity();
}
}
}
ngOnInit(): void {
this.filteredCountries = this.countryFormGroup.get('country').valueChanges.pipe(
debounceTime(150),
@ -163,7 +196,7 @@ export class CountryAutocompleteComponent implements OnInit, ControlValueAccesso
}
displayCountryFn(country?: Country): string | undefined {
return country ? `${country.flag} ${country.name}` : undefined;
return country ? country.name : undefined;
}
onFocus() {
@ -173,6 +206,21 @@ export class CountryAutocompleteComponent implements OnInit, ControlValueAccesso
}
}
checkInputAndAutoSelect() {
const control = this.countryFormGroup.get('country');
const value = control.value;
if (value && typeof value === 'string') {
const foundCountry = this.fetchCountries(value);
if (foundCountry.length === 1) {
control.setValue(foundCountry[0]);
this.autocompleteTrigger?.closePanel();
}
}
this.onTouched();
}
textIsNotEmpty(text: string): boolean {
return (text && text.length > 0);
}

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

@ -232,7 +232,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
writeValue(sec: number) {
if (sec !== this.modelValue) {
if (isDefinedAndNotNull(sec) && isNumeric(sec) && Number(sec) !== 0) {
this.timeInputForm.patchValue(this.parseTime(sec), {emitEvent: false});
this.timeInputForm.patchValue(this.parseTime(sec), {emitEvent: true});
this.modelValue = sec;
} else {
this.timeInputForm.patchValue({

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

@ -179,11 +179,11 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan
maxCalculatedFieldsPerEntity: 5,
maxArgumentsPerCF: 10,
maxDataPointsPerRollingArg: 1000,
maxRelationLevelPerCfArgument: 10,
maxRelationLevelPerCfArgument: 2,
minAllowedDeduplicationIntervalInSecForCF: 10,
minAllowedAggregationIntervalInSecForCF: 60,
maxRelatedEntitiesToReturnPerCfArgument: 100,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
minAllowedScheduledUpdateIntervalInSecForCF: 10,
intermediateAggregationIntervalInSecForCF: 300,
cfReevaluationCheckInterval: 60,
alarmsReevaluationInterval: 60,

4
ui-ngx/src/assets/dashboard/api_usage.json

@ -11412,8 +11412,8 @@
"label": "{i18n:api-usage.sms}",
"state": "sms",
"status": {
"name": "notificationApiState",
"label": "notificationApiState",
"name": "smsApiState",
"label": "smsApiState",
"type": "timeseries",
"settings": {},
"color": "#2196f3"

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

@ -516,7 +516,7 @@
"within-time": "Within time",
"within-time-pattern": "Time must be a positive integer.",
"within-time-required": "Time is required.",
"force-2fa": "Force two-factor authentication",
"force-2fa": "Enforce two-factor authentication",
"enforce-for": "Enforce for"
},
"jwt": {
@ -1000,7 +1000,8 @@
"delete-api-key-text": "Be careful, after the confirmation key will become unrecoverable.",
"delete-api-keys-title": "Are you sure you want to delete { count, plural, =1 {1 API key} other {# API keys} }?",
"delete-api-keys-text": "Be careful, after the confirmation all selected keys will become unrecoverable.",
"date": "Date",
"expiration-date": "Expiration date",
"date": "date",
"description": "Description",
"disable": "Disable",
"edit-description": "Edit description",
@ -1665,6 +1666,7 @@
"contact": {
"country": "Country",
"country-required": "Country is required.",
"country-object-required": "Please select a valid country from the list.",
"city": "City",
"state": "State / Province",
"postal-code": "Zip / Postal Code",

Loading…
Cancel
Save