Browse Source

Merge remote-tracking branch 'upstream/master' into bug/latest-values-map-with-key-value-agg

pull/14685/head
Ekaterina Chantsova 11 months ago
parent
commit
13712911bc
  1. 14
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 19
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  3. 8
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java
  4. 133
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  5. 2
      application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
  8. 45
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  9. 4
      application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java
  10. 13
      application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java
  11. 8
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  12. 6
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  13. 3
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  14. 28
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java
  15. 100
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java
  16. 5
      application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
  17. 24
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
  18. 26
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java
  19. 34
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java
  20. 14
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProcessor.java
  21. 14
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/BaseDashboardProcessor.java
  22. 17
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java
  23. 13
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java
  24. 15
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java
  25. 13
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/BaseEntityViewProcessor.java
  26. 34
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java
  27. 155
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java
  28. 101
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java
  29. 31
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserProcessor.java
  30. 13
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
  31. 2
      application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java
  32. 16
      application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java
  33. 23
      application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java
  34. 11
      application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java
  35. 15
      application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java
  36. 393
      application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
  37. 6
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  38. 3
      common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java
  39. 2
      common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java
  40. 1
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java
  41. 2
      common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java
  42. 22
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java
  43. 2
      common/edge-api/src/main/proto/edge.proto
  44. 38
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  45. 4
      ui-ngx/src/app/core/auth/auth.service.ts
  46. 5
      ui-ngx/src/app/core/services/dashboard-utils.service.ts
  47. 16
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts
  48. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  49. 10
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts
  50. 23
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts
  51. 6
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts
  52. 5
      ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html
  53. 4
      ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html
  54. 17
      ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss
  55. 5
      ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts
  56. 5
      ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html
  57. 4
      ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts
  58. 2
      ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts
  59. 38
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  60. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  61. 7
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  62. 6
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  63. 14
      ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html
  64. 4
      ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts
  65. 10
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  66. 82
      ui-ngx/src/app/modules/home/pages/security/security.component.ts
  67. 34
      ui-ngx/src/app/modules/login/login-routing.module.ts
  68. 22
      ui-ngx/src/app/modules/login/pages/login/create-password.component.html
  69. 60
      ui-ngx/src/app/modules/login/pages/login/create-password.component.ts
  70. 40
      ui-ngx/src/app/modules/login/pages/login/login.component.html
  71. 36
      ui-ngx/src/app/modules/login/pages/login/login.component.scss
  72. 20
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
  73. 60
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
  74. 35
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.html
  75. 48
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss
  76. 53
      ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts
  77. 2
      ui-ngx/src/app/shared/models/api-key.models.ts
  78. 1
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  79. 106
      ui-ngx/src/app/shared/models/password.models.ts
  80. 1
      ui-ngx/src/app/shared/models/public-api.ts
  81. 5
      ui-ngx/src/app/shared/models/time/time.models.ts
  82. 11
      ui-ngx/src/app/shared/shared.module.ts
  83. 23
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.actors;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
@ -54,7 +55,6 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.TbActorMsg;
import org.thingsboard.server.common.msg.TbMsg;
@ -119,7 +119,6 @@ import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
import org.thingsboard.server.service.cf.CalculatedFieldQueueService;
import org.thingsboard.server.service.cf.CalculatedFieldStateService;
import org.thingsboard.server.service.cf.OwnerService;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService;
@ -144,14 +143,12 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Component
@ -842,7 +839,7 @@ public class ActorSystemContext {
Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor());
}
public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, Map<String, ArgumentEntry> arguments, UUID tbMsgId, TbMsgType tbMsgType, String result, String errorMessage) {
public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, JsonNode arguments, UUID tbMsgId, String tbMsgType, String result, String errorMessage) {
if (checkLimits(tenantId)) {
try {
CalculatedFieldDebugEvent.CalculatedFieldDebugEventBuilder eventBuilder = CalculatedFieldDebugEvent.builder()
@ -855,13 +852,10 @@ public class ActorSystemContext {
eventBuilder.msgId(tbMsgId);
}
if (tbMsgType != null) {
eventBuilder.msgType(tbMsgType.name());
eventBuilder.msgType(tbMsgType);
}
if (arguments != null) {
eventBuilder.arguments(JacksonUtil.toString(
arguments.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue()))
));
eventBuilder.arguments(JacksonUtil.toString(arguments));
}
if (result != null) {
eventBuilder.result(result);

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

@ -70,6 +70,7 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.REEVALUATION_MSG;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType;
/**
@ -311,6 +312,9 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
} catch (Exception e) {
log.debug("[{}][{}] Failed to process linked CF telemetry msg: {}", entityId, ctx.getCfId(), msg, e);
if (e instanceof CalculatedFieldException cfe) {
throw cfe;
}
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
}
}
@ -351,7 +355,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
if (state.isSizeOk()) {
log.debug("[{}][{}] Reevaluating CF state", entityId, cfId);
processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, null, msg.getCallback());
processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, REEVALUATION_MSG, msg.getCallback());
} else {
throw new RuntimeException(ctx.getSizeExceedsLimitMessage());
}
@ -432,7 +436,8 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (!updatedArgs.isEmpty() || justRestored) {
cfIdList = new ArrayList<>(cfIdList);
cfIdList.add(ctx.getCfId());
processStateIfReady(state, updatedArgs, ctx, cfIdList, tbMsgId, tbMsgType, callback);
String msgType = tbMsgType == null ? null : tbMsgType.name();
processStateIfReady(state, updatedArgs, ctx, cfIdList, tbMsgId, msgType, callback);
} else {
callback.onSuccess();
}
@ -474,7 +479,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
private void processStateIfReady(CalculatedFieldState state, Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx,
List<CalculatedFieldId> cfIdList, UUID tbMsgId, TbMsgType tbMsgType, TbCallback callback) throws CalculatedFieldException {
List<CalculatedFieldId> cfIdList, UUID tbMsgId, String tbMsgType, TbCallback callback) throws CalculatedFieldException {
callback = new MultipleTbCallback(CALLBACKS_PER_CF, callback);
log.trace("[{}][{}] Processing state if ready. Current args: {}, updated args: {}", entityId, ctx.getCfId(), state.getArguments(), updatedArgs);
CalculatedFieldEntityCtxId ctxId = new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId);
@ -487,24 +492,24 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
stateSizeChecked = true;
if (state.isSizeOk()) {
if (!calculationResult.isEmpty()) {
cfService.processResult(tenantId, entityId, calculationResult, cfIdList, callback);
cfService.processResult(tenantId, entityId, ctx.getCfName(), calculationResult, cfIdList, callback);
} else {
callback.onSuccess();
}
if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) {
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.stringValue(), null);
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArgumentsJson(), tbMsgId, tbMsgType, calculationResult.stringValue(), null);
}
}
} else {
if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) {
String errorMsg = ctx.isInitialized() ? state.getReadinessStatus().errorMsg() : "Calculated field state is not initialized!";
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg);
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArgumentsJson(), tbMsgId, tbMsgType, null, errorMsg);
}
callback.onSuccess();
}
} catch (Exception e) {
log.debug("[{}][{}] Failed to process CF state", entityId, ctx.getCfId(), e);
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArguments()).cause(e).build();
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArgumentsJson()).cause(e).build();
} finally {
if (!stateSizeChecked) {
state.checkStateSize(ctxId, ctx.getMaxStateSize());

8
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java

@ -15,14 +15,12 @@
*/
package org.thingsboard.server.actors.calculatedField;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Builder;
import lombok.Getter;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.Map;
import java.util.UUID;
@Getter
@ -32,8 +30,8 @@ public class CalculatedFieldException extends Exception {
private final CalculatedFieldCtx ctx;
private final EntityId eventEntity;
private final UUID msgId;
private final TbMsgType msgType;
private Map<String, ArgumentEntry> arguments;
private final String msgType;
private JsonNode arguments;
private String errorMessage;
private Exception cause;

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

@ -15,12 +15,12 @@
*/
package org.thingsboard.server.service.cf;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import jakarta.annotation.PostConstruct;
@ -28,10 +28,12 @@ import jakarta.annotation.PreDestroy;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.AttributesSaveRequest.Strategy;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.adaptor.JsonConverter;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.CalculatedField;
@ -62,11 +64,15 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.queue.TbQueueCallback;
import org.thingsboard.server.queue.TbQueueMsgMetadata;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
@ -85,10 +91,13 @@ import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.CF_NAME_METADATA_KEY;
import static org.thingsboard.server.common.data.DataConstants.SCOPE;
import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED;
import static org.thingsboard.server.dao.util.KvUtils.filterChangedAttr;
import static org.thingsboard.server.dao.util.KvUtils.toTsKvEntryList;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry;
@ -108,6 +117,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
protected final ApiLimitService apiLimitService;
protected final RelationService relationService;
protected final OwnerService ownerService;
protected final TbClusterService clusterService;
protected ListeningExecutorService calculatedFieldCallbackExecutor;
@ -392,42 +402,46 @@ public abstract class AbstractCalculatedFieldProcessingService {
return new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, endTs, 0, limit, Aggregation.NONE);
}
protected void saveTelemetryResult(TenantId tenantId, EntityId entityId, TelemetryCalculatedFieldResult cfResult, List<CalculatedFieldId> cfIds, TbCallback callback) {
protected void sendMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbCallback callback, TbMsg msg) {
try {
clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg);
callback.onSuccess();
}
@Override
public void onFailure(Throwable t) {
callback.onFailure(t);
}
});
} catch (Exception e) {
log.warn("[{}][{}] Failed to push message to rule engine: {}", tenantId, entityId, msg, e);
callback.onFailure(e);
}
}
protected void saveTelemetryResult(TenantId tenantId, EntityId entityId, String cfName, TelemetryCalculatedFieldResult cfResult, List<CalculatedFieldId> cfIds, TbCallback callback) {
OutputType type = cfResult.getType();
JsonElement jsonResult = JsonParser.parseString(Objects.requireNonNull(cfResult.stringValue()));
log.trace("[{}][{}] Saving CF result: {}", tenantId, entityId, jsonResult);
SettableFuture<Void> future = SettableFuture.create();
switch (type) {
case ATTRIBUTES -> saveAttributes(tenantId, entityId, jsonResult, cfResult.getOutputStrategy(), cfResult.getScope(), cfIds, future);
case TIME_SERIES -> saveTimeSeries(tenantId, entityId, jsonResult, cfResult.getOutputStrategy(), cfIds, System.currentTimeMillis(), future);
case ATTRIBUTES -> saveAttributes(tenantId, entityId, jsonResult, cfResult.getOutputStrategy(), cfResult.getScope(), cfName, cfIds, callback);
case TIME_SERIES -> saveTimeSeries(tenantId, entityId, jsonResult, cfResult.getOutputStrategy(), cfIds, System.currentTimeMillis(), callback);
}
Futures.addCallback(future, new FutureCallback<>() {
@Override
public void onSuccess(Void v) {
callback.onSuccess();
log.debug("[{}][{}] Saved CF result: {}", tenantId, entityId, cfResult);
}
@Override
public void onFailure(Throwable t) {
callback.onFailure(t);
log.error("[{}][{}] Failed to save CF result {}", tenantId, entityId, cfResult, t);
}
}, MoreExecutors.directExecutor());
}
private void saveAttributes(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, AttributeScope scope, List<CalculatedFieldId> cfIds, SettableFuture<Void> future) {
private void saveAttributes(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, AttributeScope scope, String cfName, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (!(outputStrategy instanceof AttributesImmediateOutputStrategy attOutputStrategy)) {
future.setException(new IllegalArgumentException("Only AttributeImmediateOutputStrategy is supported."));
callback.onFailure(new IllegalArgumentException("Only AttributeImmediateOutputStrategy is supported."));
} else {
AttributesSaveRequest.Strategy strategy = new Strategy(attOutputStrategy.isSaveAttribute(), attOutputStrategy.isSendWsUpdate(), attOutputStrategy.isProcessCfs());
List<AttributeKvEntry> newAttributes = JsonConverter.convertToAttributes(jsonResult);
if (!attOutputStrategy.isUpdateAttributesOnlyOnValueChange()) {
saveAttributesInternal(tenantId, entityId, scope, cfIds, newAttributes, strategy, future);
saveAttributesInternal(tenantId, entityId, scope, cfName, cfIds, newAttributes, strategy, attOutputStrategy.isSendAttributesUpdatedNotification(), callback);
return;
}
@ -438,22 +452,24 @@ public abstract class AbstractCalculatedFieldProcessingService {
existingAttributes -> {
List<AttributeKvEntry> changed = filterChangedAttr(existingAttributes, newAttributes);
if (changed.isEmpty()) {
future.set(null);
callback.onSuccess();
return;
}
saveAttributesInternal(tenantId, entityId, scope, cfIds, changed, strategy, future);
saveAttributesInternal(tenantId, entityId, scope, cfName, cfIds, changed, strategy, attOutputStrategy.isSendAttributesUpdatedNotification(), callback);
},
future::setException,
callback::onFailure,
MoreExecutors.directExecutor());
}
}
private void saveAttributesInternal(TenantId tenantId, EntityId entityId,
AttributeScope scope,
String cfName,
List<CalculatedFieldId> cfIds,
List<AttributeKvEntry> entries,
AttributesSaveRequest.Strategy strategy,
SettableFuture<Void> future) {
boolean sendAttributesUpdatedNotification,
TbCallback callback) {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(entityId)
@ -461,23 +477,38 @@ public abstract class AbstractCalculatedFieldProcessingService {
.entries(entries)
.strategy(strategy)
.previousCalculatedFieldIds(cfIds)
.future(future)
.callback(new FutureCallback<>() {
@Override
public void onSuccess(Void result) {
if (sendAttributesUpdatedNotification) {
sendAttributesUpdatedMsg(tenantId, entityId, scope, cfName, entries);
}
callback.onSuccess();
log.debug("[{}][{}] Saved CF result: {}", tenantId, entityId, entries);
}
@Override
public void onFailure(Throwable t) {
callback.onFailure(t);
log.error("[{}][{}] Failed to save CF result {}", tenantId, entityId, entries, t);
}
})
.build());
}
private void saveTimeSeries(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, List<CalculatedFieldId> cfIds, long ts, SettableFuture<Void> future) {
private void saveTimeSeries(TenantId tenantId, EntityId entityId, JsonElement jsonResult, OutputStrategy outputStrategy, List<CalculatedFieldId> cfIds, long ts, TbCallback callback) {
if (!(outputStrategy instanceof TimeSeriesImmediateOutputStrategy tsOutputStrategy)) {
future.setException(new IllegalArgumentException("Only TimeSeriesImmediateOutputStrategy is supported."));
callback.onFailure(new IllegalArgumentException("Only TimeSeriesImmediateOutputStrategy is supported."));
} else {
TimeseriesSaveRequest.Strategy strategy = new TimeseriesSaveRequest.Strategy(tsOutputStrategy.isSaveTimeSeries(), tsOutputStrategy.isSaveLatest(), tsOutputStrategy.isSendWsUpdate(), tsOutputStrategy.isProcessCfs());
saveTimeSeriesInternal(tenantId, entityId, jsonResult, tsOutputStrategy.getTtl(), cfIds, ts, strategy, future);
saveTimeSeriesInternal(tenantId, entityId, jsonResult, tsOutputStrategy.getTtl(), cfIds, ts, strategy, callback);
}
}
private void saveTimeSeriesInternal(TenantId tenantId, EntityId entityId, JsonElement jsonResult, Long ttl, List<CalculatedFieldId> cfIds, long ts, TimeseriesSaveRequest.Strategy strategy, SettableFuture<Void> future) {
private void saveTimeSeriesInternal(TenantId tenantId, EntityId entityId, JsonElement jsonResult, Long ttl, List<CalculatedFieldId> cfIds, long ts, TimeseriesSaveRequest.Strategy strategy, TbCallback callback) {
Map<Long, List<KvEntry>> tsKvMap = JsonConverter.convertToTelemetry(jsonResult, ts);
if (tsKvMap.isEmpty()) {
future.set(null);
callback.onSuccess();
return;
}
List<TsKvEntry> tsEntries = toTsKvEntryList(tsKvMap);
@ -486,7 +517,19 @@ public abstract class AbstractCalculatedFieldProcessingService {
.entityId(entityId)
.entries(tsEntries)
.strategy(strategy)
.future(future);
.callback(new FutureCallback<>() {
@Override
public void onSuccess(Void result) {
callback.onSuccess();
log.debug("[{}][{}] Saved CF result: {}", tenantId, entityId, tsEntries);
}
@Override
public void onFailure(Throwable t) {
callback.onFailure(t);
log.error("[{}][{}] Failed to save CF result {}", tenantId, entityId, tsEntries, t);
}
});
if (ttl != null) {
builder.ttl(ttl);
}
@ -496,4 +539,26 @@ public abstract class AbstractCalculatedFieldProcessingService {
tsSubService.saveTimeseries(builder.build());
}
private void sendAttributesUpdatedMsg(TenantId tenantId, EntityId entityId,
AttributeScope scope,
String cfName,
List<AttributeKvEntry> entries) {
ObjectNode entityNode = JacksonUtil.newObjectNode();
if (entries != null) {
entries.forEach(attributeKvEntry -> JacksonUtil.addKvEntry(entityNode, attributeKvEntry));
}
TbMsg attributesUpdatedMsg = TbMsg.newMsg()
.type(ATTRIBUTES_UPDATED)
.originator(entityId)
.data(JacksonUtil.toString(entityNode))
.metaData(new TbMsgMetaData(Map.of(
CF_NAME_METADATA_KEY, cfName,
SCOPE, scope.name()
)))
.build();
sendMsgToRuleEngine(tenantId, entityId, TbCallback.EMPTY, attributesUpdatedMsg);
}
}

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

@ -37,7 +37,7 @@ public class AlarmCalculatedFieldResult implements CalculatedFieldResult {
private final TbAlarmResult alarmResult;
@Override
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) {
public TbMsg toTbMsg(EntityId entityId, String cfName, List<CalculatedFieldId> cfIds) {
TbMsgType msgType;
TbMsgMetaData metaData = new TbMsgMetaData();
if (alarmResult.isCreated()) {

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

@ -41,7 +41,7 @@ public interface CalculatedFieldProcessingService {
Map<String, ArgumentEntry> fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map<String, Argument> arguments);
void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback);
void processResult(TenantId tenantId, EntityId entityId, String cfName, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback);
ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval);

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

@ -23,7 +23,7 @@ import java.util.List;
public interface CalculatedFieldResult {
TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds);
TbMsg toTbMsg(EntityId entityId, String cfName, List<CalculatedFieldId> cfIds);
String stringValue();

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

@ -30,7 +30,6 @@ import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEn
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
@ -69,7 +68,6 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@Slf4j
public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedFieldProcessingService implements CalculatedFieldProcessingService {
private final TbClusterService clusterService;
private final PartitionService partitionService;
public DefaultCalculatedFieldProcessingService(AttributesService attributesService,
@ -80,8 +78,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
TbClusterService clusterService,
TelemetrySubscriptionService tsSubService,
PartitionService partitionService) {
super(attributesService, timeseriesService, tsSubService, apiLimitService, relationService, ownerService);
this.clusterService = clusterService;
super(attributesService, timeseriesService, tsSubService, apiLimitService, relationService, ownerService, clusterService);
this.partitionService = partitionService;
}
@ -136,40 +133,40 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
return super.fetchMetricDuringInterval(tenantId, entityId, argKey, metric, interval);
}
public void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
public void processResult(TenantId tenantId, EntityId entityId, String cfName, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (result instanceof AlarmCalculatedFieldResult) {
sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfIds));
sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfName, cfIds));
return;
}
TelemetryCalculatedFieldResult telemetryResult = result instanceof TelemetryCalculatedFieldResult telemetryRes
? telemetryRes : ((PropagationCalculatedFieldResult) result).getResult();
switch (telemetryResult.getOutputStrategy().getType()) {
case IMMEDIATE -> processImmediately(tenantId, entityId, result, cfIds, callback);
case RULE_CHAIN -> pushMsgToRuleEngine(tenantId, entityId, result, cfIds, callback);
case IMMEDIATE -> processImmediately(tenantId, entityId, cfName, result, cfIds, callback);
case RULE_CHAIN -> pushMsgToRuleEngine(tenantId, entityId, cfName, result, cfIds, callback);
}
}
private void processImmediately(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
private void processImmediately(TenantId tenantId, EntityId entityId, String cfName, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (result instanceof TelemetryCalculatedFieldResult telemetryResult) {
saveTelemetryResult(tenantId, entityId, telemetryResult, cfIds, callback);
saveTelemetryResult(tenantId, entityId, cfName, telemetryResult, cfIds, callback);
return;
}
if (result instanceof PropagationCalculatedFieldResult propagationResult) {
handlePropagationResults(propagationResult, callback,
(entity, res, cb) -> saveTelemetryResult(tenantId, entity, res, cfIds, cb));
(entity, res, cb) -> saveTelemetryResult(tenantId, entity, cfName, res, cfIds, cb));
return;
}
callback.onSuccess();
}
private void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
private void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, String cfName, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (result instanceof PropagationCalculatedFieldResult propagationResult) {
handlePropagationResults(propagationResult, callback,
(entity, res, cb) -> sendMsgToRuleEngine(tenantId, entity, cb, res.toTbMsg(entity, cfIds)));
(entity, res, cb) -> sendMsgToRuleEngine(tenantId, entity, cb, res.toTbMsg(entity, cfName, cfIds)));
return;
}
sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfIds));
sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfName, cfIds));
}
private void handlePropagationResults(PropagationCalculatedFieldResult propagationResult, TbCallback callback,
@ -190,26 +187,6 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
}
}
private void sendMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbCallback callback, TbMsg msg) {
try {
clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg);
callback.onSuccess();
}
@Override
public void onFailure(Throwable t) {
callback.onFailure(t);
}
});
} catch (Exception e) {
log.warn("[{}][{}] Failed to push message to rule engine: {}", tenantId, entityId, msg, e);
callback.onFailure(e);
}
}
@Override
public void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List<CalculatedFieldEntityCtxId> linkedCalculatedFields, TbCallback callback) {
Map<TopicPartitionInfo, List<CalculatedFieldEntityCtxId>> unicasts = new HashMap<>();

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

@ -32,8 +32,8 @@ public final class PropagationCalculatedFieldResult implements CalculatedFieldRe
private final TelemetryCalculatedFieldResult result;
@Override
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) {
return result.toTbMsg(entityId, cfIds);
public TbMsg toTbMsg(EntityId entityId, String cfName, List<CalculatedFieldId> cfIds) {
return result.toTbMsg(entityId, cfName, cfIds);
}
@Override

13
application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java

@ -28,8 +28,8 @@ import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.List;
import java.util.Map;
import static org.thingsboard.server.common.data.DataConstants.CF_NAME_METADATA_KEY;
import static org.thingsboard.server.common.data.DataConstants.SCOPE;
@Data
@ -44,15 +44,16 @@ public final class TelemetryCalculatedFieldResult implements CalculatedFieldResu
public static final TelemetryCalculatedFieldResult EMPTY = TelemetryCalculatedFieldResult.builder().result(null).build();
@Override
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) {
public TbMsg toTbMsg(EntityId entityId, String cfName, List<CalculatedFieldId> cfIds) {
TbMsgType msgType = switch (type) {
case ATTRIBUTES -> TbMsgType.POST_ATTRIBUTES_REQUEST;
case TIME_SERIES -> TbMsgType.POST_TELEMETRY_REQUEST;
};
TbMsgMetaData metaData = switch (type) {
case ATTRIBUTES -> new TbMsgMetaData(Map.of(SCOPE, scope.name()));
case TIME_SERIES -> TbMsgMetaData.EMPTY;
};
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue(CF_NAME_METADATA_KEY, cfName);
if (OutputType.ATTRIBUTES == type) {
metaData.putValue(SCOPE, scope.name());
}
return TbMsg.newMsg()
.type(msgType)
.originator(entityId)

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.Getter;
import lombok.Setter;
@ -35,6 +36,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Getter
public abstract class BaseCalculatedFieldState implements CalculatedFieldState, Closeable {
@ -190,4 +192,10 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
return ReadinessStatus.from(emptyArguments);
}
@Override
public JsonNode getArgumentsJson() {
return JacksonUtil.valueToTree(arguments.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue())));
}
}

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

@ -86,6 +86,7 @@ public class CalculatedFieldCtx implements Closeable {
private CalculatedField calculatedField;
private CalculatedFieldId cfId;
private String cfName;
private TenantId tenantId;
private EntityId entityId;
private CalculatedFieldType cfType;
@ -130,6 +131,7 @@ public class CalculatedFieldCtx implements Closeable {
this.calculatedField = calculatedField;
this.cfId = calculatedField.getId();
this.cfName = calculatedField.getName();
this.tenantId = calculatedField.getTenantId();
this.entityId = calculatedField.getEntityId();
this.cfType = calculatedField.getType();
@ -646,7 +648,9 @@ public class CalculatedFieldCtx implements Closeable {
}
if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration thisConfig
&& other.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration otherConfig
&& (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) {
&& (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec()
|| !thisConfig.getMetrics().equals(otherConfig.getMetrics())
|| thisConfig.isUseLatestTs() != otherConfig.isUseLatestTs())) {
return true;
}
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig

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

@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@ -60,6 +61,8 @@ public interface CalculatedFieldState extends Closeable {
Map<String, ArgumentEntry> getArguments();
JsonNode getArgumentsJson();
long getLatestTimestamp();
void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx);

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
@ -23,6 +24,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput;
@ -31,9 +33,11 @@ import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInp
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.dao.entity.EntityService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
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.aggregation.function.AggEntry;
@ -44,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ScheduledFuture;
import java.util.stream.Collectors;
import static java.util.concurrent.TimeUnit.SECONDS;
@ -62,6 +67,8 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
private ScheduledFuture<?> reevaluationFuture;
private EntityService entityService;
public RelatedEntitiesAggregationCalculatedFieldState(EntityId entityId) {
super(entityId);
}
@ -72,6 +79,7 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
metrics = configuration.getMetrics();
deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec());
entityService = ctx.getSystemContext().getEntityService();
}
@Override
@ -247,4 +255,24 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
}
}
@Override
public JsonNode getArgumentsJson() {
Map<EntityId, Map<String, ArgumentEntry>> inputs = prepareInputs();
Map<EntityId, EntityInfo> entityIdEntityInfos = entityService.fetchEntityInfos(ctx.getTenantId(), null, inputs.keySet());
List<EntityArgument> entitiesArguments = new ArrayList<>();
inputs.forEach((entityId, entityArguments) -> {
EntityInfo entityInfo = entityIdEntityInfos.get(entityId);
if (entityInfo != null) {
JsonNode entityArgumentsJson = JacksonUtil.valueToTree(entityArguments.entrySet().stream()
.collect(Collectors.toMap(Entry::getKey, e -> e.getValue().jsonValue())));
entitiesArguments.add(new EntityArgument(entityInfo, entityArgumentsJson));
}
});
return JacksonUtil.valueToTree(new RelatedEntitiesArgument(ArgumentEntryType.RELATED_ENTITIES, entitiesArguments));
}
record RelatedEntitiesArgument(ArgumentEntryType type, List<EntityArgument> entitiesArguments) {}
record EntityArgument(EntityInfo entity, JsonNode entityArguments) {}
}

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

@ -15,12 +15,16 @@
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.single;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.common.util.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;
import org.thingsboard.server.common.data.cf.configuration.Output;
@ -36,6 +40,7 @@ import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.time.Instant;
import java.time.ZoneId;
@ -58,6 +63,8 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
private long checkInterval;
private Map<String, AggMetric> metrics;
private EntityAggregationDebugArgumentsTracker debugTracker;
private CalculatedFieldProcessingService cfProcessingService;
public EntityAggregationCalculatedFieldState(EntityId entityId) {
@ -94,6 +101,15 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
createIntervalIfNotExist();
long now = System.currentTimeMillis();
if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) {
if (debugTracker == null) {
debugTracker = new EntityAggregationDebugArgumentsTracker(new HashMap<>());
} else {
debugTracker.reset();
}
debugTracker.recordUpdatedArgs(updatedArgs, arguments);
}
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results = new HashMap<>();
List<AggIntervalEntry> expiredIntervals = new ArrayList<>();
getIntervals().forEach((intervalEntry, argIntervalStatuses) -> {
@ -114,6 +130,12 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
.build());
}
@Override
public Map<String, ArgumentEntry> update(Map<String, ArgumentEntry> argumentValues, CalculatedFieldCtx ctx) {
createIntervalIfNotExist();
return super.update(argumentValues, ctx);
}
private void removeExpiredIntervals(List<AggIntervalEntry> expiredIntervals) {
expiredIntervals.forEach(expiredInterval -> {
arguments.values().stream()
@ -183,6 +205,9 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
expiredIntervals.add(intervalEntry);
} else if (now - startTs >= intervalEntry.getIntervalDuration()) {
handleActiveInterval(intervalEntry, args, results);
if (watermarkDuration == 0) {
expiredIntervals.add(intervalEntry);
}
}
}
@ -262,14 +287,89 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt
resultNode.put("ts", interval.getEndTs() - 1);
resultNode.set("values", metricsNode);
result.add(resultNode);
if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) {
if (debugTracker != null) {
debugTracker.addInterval(interval);
}
}
}
});
return result;
}
@Override
public JsonNode getArgumentsJson() {
if (debugTracker == null) {
return null;
}
EntityAggregationDebugArguments debugArguments = debugTracker.toDebugArguments();
return debugArguments == null ? null : JacksonUtil.valueToTree(debugArguments);
}
@Override
public boolean isReady() {
return true;
}
record EntityAggregationDebugArgumentsTracker(Map<AggIntervalEntry, Map<String, TbelCfArg>> processedIntervals) {
public void reset() {
processedIntervals.clear();
}
public void addInterval(AggIntervalEntry interval) {
processedIntervals.computeIfAbsent(interval, k -> new HashMap<>());
}
public void recordUpdatedArgs(Map<String, ArgumentEntry> updatedArgs, Map<String, ArgumentEntry> arguments) {
if (updatedArgs != null && !updatedArgs.isEmpty()) {
updatedArgs.forEach((argName, argEntry) -> {
ArgumentEntry argumentEntry = arguments.get(argName);
if (argumentEntry instanceof EntityAggregationArgumentEntry entityAggEntry && argEntry instanceof SingleValueArgumentEntry singleEntry) {
entityAggEntry.getAggIntervals().forEach((aggIntervalEntry, aggIntervalEntryStatus) -> {
boolean match = singleEntry.isForceResetPrevious() || aggIntervalEntry.belongsToInterval(singleEntry.getTs());
if (match) {
recordArg(aggIntervalEntry, argName, singleEntry.toTbelCfArg());
}
});
}
});
}
}
public void recordArg(AggIntervalEntry interval, String argName, TbelCfArg value) {
processedIntervals.computeIfAbsent(interval, k -> new HashMap<>()).put(argName, value);
}
public EntityAggregationDebugArguments toDebugArguments() {
if (processedIntervals.isEmpty()) {
return null;
}
return EntityAggregationDebugArguments.toDebugArguments(processedIntervals);
}
}
record EntityAggregationDebugArguments(List<IntervalDebugArgument> processedIntervals) {
public static EntityAggregationDebugArguments toDebugArguments(Map<AggIntervalEntry, Map<String, TbelCfArg>> processedIntervals) {
List<IntervalDebugArgument> result = new ArrayList<>();
processedIntervals.forEach((interval, args) -> {
result.add(new IntervalDebugArgument(interval.getStartTs(), interval.getEndTs(), args));
});
return new EntityAggregationDebugArguments(result);
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
record IntervalDebugArgument(Long intervalStartTs, Long intervalEndTs, JsonNode updatedArguments) {
public IntervalDebugArgument(Long intervalStartTs, Long intervalEndTs, Map<String, TbelCfArg> updatedArguments) {
this(intervalStartTs, intervalEndTs, updatedArguments == null || updatedArguments.isEmpty() ? null : JacksonUtil.valueToTree(updatedArguments));
}
}
}

5
application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java

@ -75,6 +75,7 @@ import org.thingsboard.server.service.edge.rpc.processor.resource.ResourceEdgePr
import org.thingsboard.server.service.edge.rpc.processor.rule.RuleChainEdgeProcessor;
import org.thingsboard.server.service.edge.rpc.processor.rule.RuleChainMetadataEdgeProcessor;
import org.thingsboard.server.service.edge.rpc.processor.telemetry.TelemetryEdgeProcessor;
import org.thingsboard.server.service.edge.rpc.processor.user.UserProcessor;
import org.thingsboard.server.service.edge.rpc.sync.EdgeRequestsService;
import org.thingsboard.server.service.executors.GrpcCallbackExecutorService;
@ -265,9 +266,13 @@ public class EdgeContextComponent {
@Autowired
private AiModelService aiModelService;
@Autowired
private AiModelProcessor aiModelProcessor;
@Autowired
private UserProcessor userProcessor;
public EdgeProcessor getProcessor(EdgeEventType edgeEventType) {
EdgeProcessor processor = processorMap.get(edgeEventType);
if (processor == null) {

24
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java

@ -84,6 +84,8 @@ import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
@ -101,6 +103,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@ -122,6 +125,7 @@ public abstract class EdgeGrpcSession implements Closeable {
private final EdgeSessionState sessionState = new EdgeSessionState();
private final ReentrantLock downlinkMsgLock = new ReentrantLock();
private final Lock sequenceDependencyLock = new ReentrantLock();
protected EdgeContextComponent ctx;
protected Edge edge;
@ -940,6 +944,26 @@ public abstract class EdgeGrpcSession implements Closeable {
result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg));
}
}
if (uplinkMsg.getUserUpdateMsgCount() > 0) {
for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) {
sequenceDependencyLock.lock();
try {
result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg));
} finally {
sequenceDependencyLock.unlock();
}
}
}
if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) {
for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) {
sequenceDependencyLock.lock();
try {
result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg));
} finally {
sequenceDependencyLock.unlock();
}
}
}
} catch (Exception e) {
String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e);

26
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java

@ -19,12 +19,14 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.HasCustomerId;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventActionType;
@ -37,6 +39,7 @@ import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
@ -348,6 +351,29 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor {
edgeCtx.getRelationService().saveRelation(tenantId, relation);
}
protected <T extends HasId<? extends EntityId>> void pushEntityEventToRuleEngine(TenantId tenantId, T entity, TbMsgType msgType) {
pushEntityEventToRuleEngine(tenantId, null, entity, msgType);
}
protected <T extends HasId<? extends EntityId>> void pushEntityEventToRuleEngine(TenantId tenantId, Edge edge, T entity, TbMsgType msgType) {
try {
String entityAsString = JacksonUtil.toString(entity);
CustomerId customerId = getCustomerId(entity);
TbMsgMetaData tbMsgMetaData = edge == null ? TbMsgMetaData.EMPTY : getEdgeActionTbMsgMetaData(edge, customerId);
pushEntityEventToRuleEngine(tenantId, entity.getId(), customerId, msgType, entityAsString, tbMsgMetaData);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push entity action for {} to rule engine: {}", tenantId, entity.getId(), entity.getId().getEntityType(), msgType.name(), e);
}
}
private <T extends HasId<? extends EntityId>> CustomerId getCustomerId(T entity) {
if (entity instanceof HasCustomerId hasCustomer) {
return hasCustomer.getCustomerId();
}
return null;
}
protected TbMsgMetaData getEdgeActionTbMsgMetaData(Edge edge, CustomerId customerId) {
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("edgeId", edge.getId().toString());

34
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java

@ -55,21 +55,17 @@ public class AssetEdgeProcessor extends BaseAssetProcessor implements AssetProce
try {
edgeSynchronizationManager.getEdgeId().set(edge.getId());
switch (assetUpdateMsg.getMsgType()) {
case ENTITY_CREATED_RPC_MESSAGE:
case ENTITY_UPDATED_RPC_MESSAGE:
return switch (assetUpdateMsg.getMsgType()) {
case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> {
saveOrUpdateAsset(tenantId, assetId, assetUpdateMsg, edge);
return Futures.immediateFuture(null);
case ENTITY_DELETED_RPC_MESSAGE:
Asset assetToDelete = edgeCtx.getAssetService().findAssetById(tenantId, assetId);
if (assetToDelete != null) {
edgeCtx.getAssetService().unassignAssetFromEdge(tenantId, assetId, edge.getId());
}
return Futures.immediateFuture(null);
case UNRECOGNIZED:
default:
return handleUnsupportedMsgType(assetUpdateMsg.getMsgType());
}
yield Futures.immediateFuture(null);
}
case ENTITY_DELETED_RPC_MESSAGE -> {
deleteAsset(tenantId, edge, assetId);
yield Futures.immediateFuture(null);
}
default -> handleUnsupportedMsgType(assetUpdateMsg.getMsgType());
};
} catch (DataValidationException e) {
if (e.getMessage().contains("limit reached")) {
log.warn("[{}] Number of allowed asset violated {}", tenantId, assetUpdateMsg, e);
@ -97,14 +93,8 @@ public class AssetEdgeProcessor extends BaseAssetProcessor implements AssetProce
}
private void pushAssetCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AssetId assetId) {
try {
Asset asset = edgeCtx.getAssetService().findAssetById(tenantId, assetId);
String assetAsString = JacksonUtil.toString(asset);
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, asset.getCustomerId());
pushEntityEventToRuleEngine(tenantId, assetId, asset.getCustomerId(), TbMsgType.ENTITY_CREATED, assetAsString, msgMetaData);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push asset action to rule engine: {}", tenantId, assetId, TbMsgType.ENTITY_CREATED.name(), e);
}
Asset asset = edgeCtx.getAssetService().findAssetById(tenantId, assetId);
pushEntityEventToRuleEngine(tenantId, edge, asset, TbMsgType.ENTITY_CREATED);
}
@Override

14
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProcessor.java

@ -21,9 +21,11 @@ import org.springframework.data.util.Pair;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
@ -77,4 +79,16 @@ public abstract class BaseAssetProcessor extends BaseEdgeProcessor {
protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, Asset asset, AssetUpdateMsg assetUpdateMsg);
protected void deleteAsset(TenantId tenantId, AssetId assetId) {
deleteAsset(tenantId, null, assetId);
}
protected void deleteAsset(TenantId tenantId, Edge edge, AssetId assetId) {
Asset assetById = edgeCtx.getAssetService().findAssetById(tenantId, assetId);
if (assetById != null) {
edgeCtx.getAssetService().deleteAsset(tenantId, assetId);
pushEntityEventToRuleEngine(tenantId, edge, assetById, TbMsgType.ENTITY_DELETED);
}
}
}

14
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/BaseDashboardProcessor.java

@ -20,9 +20,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.ShortCustomerInfo;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
@ -100,6 +102,18 @@ public abstract class BaseDashboardProcessor extends BaseEdgeProcessor {
}
}
protected void deleteDashboard(TenantId tenantId, DashboardId dashboardId) {
deleteDashboard(tenantId, null, dashboardId);
}
protected void deleteDashboard(TenantId tenantId, Edge edge, DashboardId dashboardId) {
Dashboard dashboardById = edgeCtx.getDashboardService().findDashboardById(tenantId, dashboardId);
if (dashboardById != null) {
edgeCtx.getDashboardService().deleteDashboard(tenantId, dashboardId);
pushEntityEventToRuleEngine(tenantId, edge, dashboardById, TbMsgType.ENTITY_DELETED);
}
}
protected abstract Set<ShortCustomerInfo> filterNonExistingCustomers(TenantId tenantId, Set<ShortCustomerInfo> currentAssignedCustomers, Set<ShortCustomerInfo> newAssignedCustomers);
}

17
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java

@ -19,7 +19,6 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.ShortCustomerInfo;
@ -29,7 +28,6 @@ import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
@ -59,10 +57,7 @@ public class DashboardEdgeProcessor extends BaseDashboardProcessor implements Da
saveOrUpdateDashboard(tenantId, dashboardId, dashboardUpdateMsg, edge);
return Futures.immediateFuture(null);
case ENTITY_DELETED_RPC_MESSAGE:
Dashboard dashboardToDelete = edgeCtx.getDashboardService().findDashboardById(tenantId, dashboardId);
if (dashboardToDelete != null) {
edgeCtx.getDashboardService().unassignDashboardFromEdge(tenantId, dashboardId, edge.getId());
}
deleteDashboard(tenantId, edge, dashboardId);
return Futures.immediateFuture(null);
case UNRECOGNIZED:
default:
@ -90,14 +85,8 @@ public class DashboardEdgeProcessor extends BaseDashboardProcessor implements Da
}
private void pushDashboardCreatedEventToRuleEngine(TenantId tenantId, Edge edge, DashboardId dashboardId) {
try {
Dashboard dashboard = edgeCtx.getDashboardService().findDashboardById(tenantId, dashboardId);
String dashboardAsString = JacksonUtil.toString(dashboard);
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, null);
pushEntityEventToRuleEngine(tenantId, dashboardId, null, TbMsgType.ENTITY_CREATED, dashboardAsString, msgMetaData);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push dashboard action to rule engine: {}", tenantId, dashboardId, TbMsgType.ENTITY_CREATED.name(), e);
}
Dashboard dashboard = edgeCtx.getDashboardService().findDashboardById(tenantId, dashboardId);
pushEntityEventToRuleEngine(tenantId, edge, dashboard, TbMsgType.ENTITY_CREATED);
}
@Override

13
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java

@ -21,9 +21,11 @@ import org.springframework.data.util.Pair;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg;
@ -110,4 +112,15 @@ public abstract class BaseDeviceProcessor extends BaseEdgeProcessor {
protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, Device device, DeviceUpdateMsg deviceUpdateMsg);
protected void deleteDevice(TenantId tenantId, DeviceId deviceId) {
deleteDevice(tenantId, null, deviceId);
}
protected void deleteDevice(TenantId tenantId, Edge edge, DeviceId deviceId) {
Device deviceById = edgeCtx.getDeviceService().findDeviceById(tenantId, deviceId);
if (deviceById != null) {
edgeCtx.getDeviceService().deleteDevice(tenantId, deviceId);
pushEntityEventToRuleEngine(tenantId, edge, deviceById, TbMsgType.ENTITY_DELETED);
}
}
}

15
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java

@ -76,10 +76,7 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor implements DevicePr
saveOrUpdateDevice(tenantId, deviceId, deviceUpdateMsg, edge);
return Futures.immediateFuture(null);
case ENTITY_DELETED_RPC_MESSAGE:
Device deviceToDelete = edgeCtx.getDeviceService().findDeviceById(tenantId, deviceId);
if (deviceToDelete != null) {
edgeCtx.getDeviceService().unassignDeviceFromEdge(tenantId, deviceId, edge.getId());
}
deleteDevice(tenantId, edge, deviceId);
return Futures.immediateFuture(null);
case UNRECOGNIZED:
default:
@ -125,14 +122,8 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor implements DevicePr
}
private void pushDeviceCreatedEventToRuleEngine(TenantId tenantId, Edge edge, DeviceId deviceId) {
try {
Device device = edgeCtx.getDeviceService().findDeviceById(tenantId, deviceId);
String deviceAsString = JacksonUtil.toString(device);
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, device.getCustomerId());
pushEntityEventToRuleEngine(tenantId, deviceId, device.getCustomerId(), TbMsgType.ENTITY_CREATED, deviceAsString, msgMetaData);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push device action to rule engine: {}", tenantId, deviceId, TbMsgType.ENTITY_CREATED.name(), e);
}
Device device = edgeCtx.getDeviceService().findDeviceById(tenantId, deviceId);
pushEntityEventToRuleEngine(tenantId, edge, device, TbMsgType.ENTITY_CREATED);
}
@Override

13
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/BaseEntityViewProcessor.java

@ -21,9 +21,11 @@ import org.springframework.data.util.Pair;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
@ -69,4 +71,15 @@ public abstract class BaseEntityViewProcessor extends BaseEdgeProcessor {
protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, EntityView entityView, EntityViewUpdateMsg entityViewUpdateMsg);
protected void deleteEntityView(TenantId tenantId, EntityViewId entityViewId) {
deleteEntityView(tenantId, null, entityViewId);
}
protected void deleteEntityView(TenantId tenantId, Edge edge, EntityViewId entityViewId) {
EntityView entityViewById = edgeCtx.getEntityViewService().findEntityViewById(tenantId, entityViewId);
if (entityViewById != null) {
edgeCtx.getEntityViewService().deleteEntityView(tenantId, entityViewId);
pushEntityEventToRuleEngine(tenantId, edge, entityViewById, TbMsgType.ENTITY_DELETED);
}
}
}

34
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java

@ -54,21 +54,17 @@ public class EntityViewEdgeProcessor extends BaseEntityViewProcessor implements
try {
edgeSynchronizationManager.getEdgeId().set(edge.getId());
switch (entityViewUpdateMsg.getMsgType()) {
case ENTITY_CREATED_RPC_MESSAGE:
case ENTITY_UPDATED_RPC_MESSAGE:
return switch (entityViewUpdateMsg.getMsgType()) {
case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> {
saveOrUpdateEntityView(tenantId, entityViewId, entityViewUpdateMsg, edge);
return Futures.immediateFuture(null);
case ENTITY_DELETED_RPC_MESSAGE:
EntityView entityViewToDelete = edgeCtx.getEntityViewService().findEntityViewById(tenantId, entityViewId);
if (entityViewToDelete != null) {
edgeCtx.getEntityViewService().unassignEntityViewFromEdge(tenantId, entityViewId, edge.getId());
}
return Futures.immediateFuture(null);
case UNRECOGNIZED:
default:
return handleUnsupportedMsgType(entityViewUpdateMsg.getMsgType());
}
yield Futures.immediateFuture(null);
}
case ENTITY_DELETED_RPC_MESSAGE -> {
deleteEntityView(tenantId, edge, entityViewId);
yield Futures.immediateFuture(null);
}
default -> handleUnsupportedMsgType(entityViewUpdateMsg.getMsgType());
};
} catch (DataValidationException e) {
if (e.getMessage().contains("limit reached")) {
log.warn("[{}] Number of allowed entity views violated {}", tenantId, entityViewUpdateMsg, e);
@ -96,14 +92,8 @@ public class EntityViewEdgeProcessor extends BaseEntityViewProcessor implements
}
private void pushEntityViewCreatedEventToRuleEngine(TenantId tenantId, Edge edge, EntityViewId entityViewId) {
try {
EntityView entityView = edgeCtx.getEntityViewService().findEntityViewById(tenantId, entityViewId);
String entityViewAsString = JacksonUtil.toString(entityView);
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, entityView.getCustomerId());
pushEntityEventToRuleEngine(tenantId, entityViewId, entityView.getCustomerId(), TbMsgType.ENTITY_CREATED, entityViewAsString, msgMetaData);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push entity view action to rule engine: {}", tenantId, entityViewId, TbMsgType.ENTITY_CREATED.name(), e);
}
EntityView entityView = edgeCtx.getEntityViewService().findEntityViewById(tenantId, entityViewId);
pushEntityEventToRuleEngine(tenantId, edge, entityView, TbMsgType.ENTITY_CREATED);
}
@Override

155
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java

@ -0,0 +1,155 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor.user;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.util.Pair;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
@Slf4j
public abstract class BaseUserProcessor extends BaseEdgeProcessor {
@Autowired
private DataValidator<User> userValidator;
protected Pair<Boolean, Boolean> saveOrUpdateUser(TenantId tenantId, UserId userId, UserUpdateMsg userUpdateMsg) {
boolean isCreated = false;
boolean userEmailUpdated = false;
try {
User user = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
if (user == null) {
throw new IllegalArgumentException(String.format("[%s] Failed to parse User from UserUpdateMsg: %s", tenantId, userUpdateMsg));
}
User userById = edgeCtx.getUserService().findUserById(tenantId, userId);
if (userById == null) {
isCreated = true;
user.setId(null);
} else {
user.setId(userId);
}
String userEmail = user.getEmail();
User existing = edgeCtx.getUserService().findUserByTenantIdAndEmail(tenantId, user.getEmail());
if (existing != null && !existing.getId().equals(user.getId())) {
String[] splitEmail = userEmail.split("@");
userEmail = splitEmail[0] + "_" + StringUtils.randomAlphanumeric(15) + "@" + splitEmail[1];
log.warn("[{}] User with email {} already exists. Renaming User email to {}",
tenantId, user.getEmail(), userEmail);
userEmailUpdated = true;
}
user.setEmail(userEmail);
setCustomerId(tenantId, isCreated ? null : userById.getCustomerId(), user, userUpdateMsg);
userValidator.validate(user, User::getTenantId);
if (isCreated) {
user.setId(userId);
}
edgeCtx.getUserService().saveUser(tenantId, user, false);
} catch (Exception e) {
log.error("[{}] Failed to process user update msg [{}]", tenantId, userUpdateMsg, e);
throw e;
}
return Pair.of(isCreated, userEmailUpdated);
}
protected void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId) {
deleteUserAndPushEntityDeletedEventToRuleEngine(tenantId, userId, null);
}
protected void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId, Edge edge) {
User removedUser = deleteUser(tenantId, userId);
if (removedUser == null) {
return;
}
CustomerId userCustomerId = removedUser.getCustomerId();
String userAsString = JacksonUtil.toString(removedUser);
TbMsgMetaData msgMetaData = edge == null ? new TbMsgMetaData() : getEdgeActionTbMsgMetaData(edge, userCustomerId);
addRemovedUserMetadata(msgMetaData, removedUser);
pushEntityEventToRuleEngine(tenantId, userId, userCustomerId, TbMsgType.ENTITY_DELETED, userAsString, msgMetaData);
}
private User deleteUser(TenantId tenantId, UserId userId) {
User userById = edgeCtx.getUserService().findUserById(tenantId, userId);
if (userById == null) {
log.trace("[{}] User with id {} does not exist", tenantId, userId);
return null;
}
edgeCtx.getUserService().deleteUser(tenantId, userById);
return userById;
}
protected void updateUserCredentials(TenantId tenantId, UserCredentialsUpdateMsg updateMsg) {
UserCredentials userCredentials = JacksonUtil.fromString(updateMsg.getEntity(), UserCredentials.class, true);
if (userCredentials == null) {
throw new IllegalArgumentException(String.format("[%s] Failed to parse UserCredentials from updateMsg: %s", tenantId, updateMsg));
}
User user = edgeCtx.getUserService().findUserById(tenantId, userCredentials.getUserId());
if (user == null) {
log.warn("[{}] Can't find user by id [{}] skipping credentials update. UserCredentialsUpdateMsg [{}]",
tenantId, userCredentials.getUserId(), updateMsg);
return;
}
log.debug("[{}] Updating user credentials for user [{}]. New credentials Id [{}], enabled [{}]",
tenantId, user.getName(), userCredentials.getId(), userCredentials.isEnabled());
try {
UserCredentials userCredentialsByUserId = edgeCtx.getUserService().findUserCredentialsByUserId(tenantId, user.getId());
if (userCredentialsByUserId != null && !userCredentialsByUserId.getId().equals(userCredentials.getId())) {
edgeCtx.getUserService().deleteUserCredentials(tenantId, userCredentialsByUserId);
}
edgeCtx.getUserService().saveUserCredentials(tenantId, userCredentials, false);
} catch (Exception e) {
log.error("[{}] Can't update user credentials for user [{}], userCredentialsUpdateMsg [{}]",
tenantId, user.getName(), updateMsg, e);
throw new RuntimeException(e);
}
}
private void addRemovedUserMetadata(TbMsgMetaData metaData, User removedUser) {
metaData.putValue("userId", removedUser.getId().toString());
metaData.putValue("userName", removedUser.getName());
metaData.putValue("userEmail", removedUser.getEmail());
if (removedUser.getFirstName() != null) {
metaData.putValue("userFirstName", removedUser.getFirstName());
}
if (removedUser.getLastName() != null) {
metaData.putValue("userLastName", removedUser.getLastName());
}
}
protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, User user, UserUpdateMsg userUpdateMsg);
}

101
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java

@ -15,26 +15,110 @@
*/
package org.thingsboard.server.service.edge.rpc.processor.user;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventActionType;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
import java.util.UUID;
@Slf4j
@Component
@TbCoreComponent
public class UserEdgeProcessor extends BaseEdgeProcessor {
public class UserEdgeProcessor extends BaseUserProcessor implements UserProcessor {
@Override
public ListenableFuture<Void> processUserMsgFromEdge(TenantId tenantId, Edge edge, UserUpdateMsg userUpdateMsg) {
log.trace("[{}] executing processUserMsgFromEdge [{}] from edge [{}]", tenantId, userUpdateMsg, edge.getId());
UserId userId = new UserId(new UUID(userUpdateMsg.getIdMSB(), userUpdateMsg.getIdLSB()));
try {
edgeSynchronizationManager.getEdgeId().set(edge.getId());
return switch (userUpdateMsg.getMsgType()) {
case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> {
saveOrUpdateUser(tenantId, userId, userUpdateMsg, edge);
yield Futures.immediateFuture(null);
}
case ENTITY_DELETED_RPC_MESSAGE -> {
deleteUserAndPushEntityDeletedEventToRuleEngine(tenantId, userId, edge);
yield Futures.immediateFuture(null);
}
default -> handleUnsupportedMsgType(userUpdateMsg.getMsgType());
};
} catch (DataValidationException e) {
if (e.getMessage().contains("limit reached")) {
log.warn("[{}] Number of allowed users violated {}", tenantId, userUpdateMsg, e);
return Futures.immediateFuture(null);
} else {
return Futures.immediateFailedFuture(e);
}
} finally {
edgeSynchronizationManager.getEdgeId().remove();
}
}
@Override
public ListenableFuture<Void> processUserCredentialsMsgFromEdge(TenantId tenantId, Edge edge, UserCredentialsUpdateMsg userCredentialsUpdateMsg) {
log.debug("[{}] Executing processUserCredentialsMsgFromEdge, userCredentialsUpdateMsg [{}]", tenantId, userCredentialsUpdateMsg);
try {
edgeSynchronizationManager.getEdgeId().set(edge.getId());
super.updateUserCredentials(tenantId, userCredentialsUpdateMsg);
} finally {
edgeSynchronizationManager.getEdgeId().remove();
}
return Futures.immediateFuture(null);
}
private void saveOrUpdateUser(TenantId tenantId, UserId userId, UserUpdateMsg userUpdateMsg, Edge edge) {
Pair<Boolean, Boolean> resultPair = super.saveOrUpdateUser(tenantId, userId, userUpdateMsg);
boolean isCreated = resultPair.getFirst();
if (isCreated) {
createRelationFromEdge(tenantId, edge.getId(), userId);
pushUserCreatedEventToRuleEngine(tenantId, edge, userId);
}
boolean userEmailUpdated = resultPair.getSecond();
if (userEmailUpdated) {
saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.USER, EdgeEventActionType.UPDATED, userId, null);
}
}
private void pushUserCreatedEventToRuleEngine(TenantId tenantId, Edge edge, UserId userId) {
try {
User user = edgeCtx.getUserService().findUserById(tenantId, userId);
if (user != null) {
String userAsString = JacksonUtil.toString(user);
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, user.getCustomerId());
pushEntityEventToRuleEngine(tenantId, userId, user.getCustomerId(), TbMsgType.ENTITY_CREATED, userAsString, msgMetaData);
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to push user action to rule engine: {}", tenantId, userId, TbMsgType.ENTITY_CREATED.name(), e);
}
}
@Override
public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) {
@ -48,7 +132,7 @@ public class UserEdgeProcessor extends BaseEdgeProcessor {
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.addUserUpdateMsg(EdgeMsgConstructorUtils.constructUserUpdatedMsg(msgType, user));
UserCredentials userCredentialsByUserId = edgeCtx.getUserService().findUserCredentialsByUserId(edgeEvent.getTenantId(), userId);
if (userCredentialsByUserId != null && userCredentialsByUserId.isEnabled()) {
if (userCredentialsByUserId != null) {
builder.addUserCredentialsUpdateMsg(EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId));
}
return builder.build();
@ -62,11 +146,10 @@ public class UserEdgeProcessor extends BaseEdgeProcessor {
}
case CREDENTIALS_UPDATED -> {
UserCredentials userCredentialsByUserId = edgeCtx.getUserService().findUserCredentialsByUserId(edgeEvent.getTenantId(), userId);
if (userCredentialsByUserId != null && userCredentialsByUserId.isEnabled()) {
UserCredentialsUpdateMsg userCredentialsUpdateMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId);
if (userCredentialsByUserId != null) {
return DownlinkMsg.newBuilder()
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.addUserCredentialsUpdateMsg(userCredentialsUpdateMsg)
.addUserCredentialsUpdateMsg(EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId))
.build();
}
}
@ -79,4 +162,10 @@ public class UserEdgeProcessor extends BaseEdgeProcessor {
return EdgeEventType.USER;
}
@Override
protected void setCustomerId(TenantId tenantId, CustomerId customerId, User user, UserUpdateMsg userUpdateMsg) {
CustomerId customerUUID = user.getCustomerId() != null ? user.getCustomerId() : customerId;
user.setCustomerId(customerUUID);
}
}

31
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserProcessor.java

@ -0,0 +1,31 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor.user;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor;
public interface UserProcessor extends EdgeProcessor {
ListenableFuture<Void> processUserMsgFromEdge(TenantId tenantId, Edge edge, UserUpdateMsg userUpdateMsg);
ListenableFuture<Void> processUserCredentialsMsgFromEdge(TenantId tenantId, Edge edge, UserCredentialsUpdateMsg userCredentialsUpdateMsg);
}

13
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java

@ -627,14 +627,11 @@ public class DefaultTbClusterService implements TbClusterService {
// No need to push notifications twice
tbRuleEngineServices.removeAll(tbCoreServices);
}
boolean toRuleEngine = entityType != EntityType.USER;
if (toRuleEngine) {
for (String serviceId : tbRuleEngineServices) {
TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId);
ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build();
toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null);
toRuleEngineNfs.incrementAndGet();
}
for (String serviceId : tbRuleEngineServices) {
TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId);
ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build();
toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null);
toRuleEngineNfs.incrementAndGet();
}
}

2
application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java

@ -29,13 +29,11 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@TbCoreComponent
@RequiredArgsConstructor
public class DefaultUserAuthDetailsCache implements UserAuthDetailsCache {

16
application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.edge;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.protobuf.AbstractMessage;
import org.junit.Assert;
import org.junit.Test;
@ -28,8 +27,6 @@ import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg;
@ -37,10 +34,11 @@ import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
@ -277,12 +275,10 @@ public class AssetEdgeTest extends AbstractEdgeTest {
edgeImitator.expectResponsesAmount(1);
edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
AssetInfo assetInfo = doGet("/api/asset/info/" + savedAsset.getUuidId(), AssetInfo.class);
Assert.assertNotNull(assetInfo);
List<AssetInfo> edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/assets?",
new TypeReference<PageData<AssetInfo>>() {
}, new PageLink(100)).getData();
Assert.assertFalse(edgeAssets.contains(assetInfo));
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
doGet("/api/asset/info/" + savedAsset.getUuidId(), AssetInfo.class, status().isNotFound())
);
}
private Asset saveAssetOnCloudAndVerifyDeliveryToEdge() throws Exception {

23
application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java

@ -36,10 +36,11 @@ import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
@ -227,7 +228,7 @@ public class DashboardEdgeTest extends AbstractEdgeTest {
}
@Test
public void testSendDeleteEntityViewOnEdgeToCloud() throws Exception {
public void testSendDeleteDashboardOnEdgeToCloud() throws Exception {
Dashboard savedDashboard = saveDashboardOnCloudAndVerifyDeliveryToEdge();
UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder();
@ -244,12 +245,10 @@ public class DashboardEdgeTest extends AbstractEdgeTest {
edgeImitator.expectResponsesAmount(1);
edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
DashboardInfo dashboardInfo = doGet("/api/dashboard/info/" + savedDashboard.getUuidId(), DashboardInfo.class);
Assert.assertNotNull(dashboardInfo);
List<DashboardInfo> edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/dashboards?",
new TypeReference<PageData<DashboardInfo>>() {
}, new PageLink(100)).getData();
Assert.assertFalse(edgeAssets.contains(dashboardInfo));
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
doGet("/api/dashboard/info/" + savedDashboard.getUuidId(), DashboardInfo.class, status().isNotFound())
);
}
private Dashboard saveDashboardOnCloudAndVerifyDeliveryToEdge() throws Exception {
@ -263,10 +262,10 @@ public class DashboardEdgeTest extends AbstractEdgeTest {
Assert.assertTrue(edgeImitator.waitForMessages());
Optional<DashboardUpdateMsg> dashboardUpdateMsgOpt = edgeImitator.findMessageByType(DashboardUpdateMsg.class);
Assert.assertTrue(dashboardUpdateMsgOpt.isPresent());
DashboardUpdateMsg entityViewUpdateMsg = dashboardUpdateMsgOpt.get();
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType());
Assert.assertEquals(savedDashboard.getUuidId().getMostSignificantBits(), entityViewUpdateMsg.getIdMSB());
Assert.assertEquals(savedDashboard.getUuidId().getLeastSignificantBits(), entityViewUpdateMsg.getIdLSB());
DashboardUpdateMsg dashboardUpdateMsg = dashboardUpdateMsgOpt.get();
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType());
Assert.assertEquals(savedDashboard.getUuidId().getMostSignificantBits(), dashboardUpdateMsg.getIdMSB());
Assert.assertEquals(savedDashboard.getUuidId().getLeastSignificantBits(), dashboardUpdateMsg.getIdLSB());
return savedDashboard;
}

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

@ -79,6 +79,7 @@ import java.util.Random;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@ -413,12 +414,10 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
edgeImitator.expectResponsesAmount(1);
edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
DeviceInfo deviceInfo = doGet("/api/device/info/" + savedDevice.getUuidId(), DeviceInfo.class);
Assert.assertNotNull(deviceInfo);
List<DeviceInfo> edgeDevices = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/devices?",
new TypeReference<PageData<DeviceInfo>>() {
}, new PageLink(100)).getData();
Assert.assertFalse(edgeDevices.contains(deviceInfo));
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
doGet("/api/device/info/" + savedDevice.getUuidId(), DeviceInfo.class, status().isNotFound())
);
}
@Test

15
application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.edge;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.protobuf.AbstractMessage;
import com.google.protobuf.InvalidProtocolBufferException;
import org.junit.Assert;
@ -31,8 +30,6 @@ import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg;
@ -40,10 +37,11 @@ import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
@ -256,12 +254,9 @@ public class EntityViewEdgeTest extends AbstractEdgeTest {
edgeImitator.expectResponsesAmount(1);
edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
EntityViewInfo entityViewInfo = doGet("/api/entityView/info/" + savedEntityView.getUuidId(), EntityViewInfo.class);
Assert.assertNotNull(entityViewInfo);
List<EntityViewInfo> edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/entityViews?",
new TypeReference<PageData<EntityViewInfo>>() {
}, new PageLink(100)).getData();
Assert.assertFalse(edgeAssets.contains(entityViewInfo));
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
doGet("/api/entityView/info/" + savedEntityView.getUuidId(), EntityViewInfo.class, status().isNotFound())
);
}
private void verifyEntityViewUpdateMsg(EntityView entityView, Device device) throws InvalidProtocolBufferException {

393
application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java

@ -15,15 +15,22 @@
*/
package org.thingsboard.server.edge;
import com.google.protobuf.AbstractMessage;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.web.servlet.ResultMatcher;
import org.testcontainers.shaded.org.awaitility.Awaitility;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.UserCredentialsId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.service.DaoSqlTest;
@ -32,11 +39,15 @@ import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.security.model.ChangePasswordRequest;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.dao.user.UserServiceImpl.DEFAULT_TOKEN_LENGTH;
@DaoSqlTest
public class UserEdgeTest extends AbstractEdgeTest {
@ -44,216 +55,304 @@ public class UserEdgeTest extends AbstractEdgeTest {
@Autowired
private BCryptPasswordEncoder passwordEncoder;
private static final String DEFAULT_FIRST_NAME = "Boris";
private static final String DEFAULT_LAST_NAME = "Johnson";
private static final String UPDATED_LAST_NAME = "Borisov";
private static final String DEFAULT_TENANT_ADMIN_EMAIL = "tenantAdmin@thingsboard.org";
private static final String DEFAULT_CUSTOMER_USER_EMAIL = "customerUser@thingsboard.org";
@Test
public void testCreateUpdateDeleteTenantUser() throws Exception {
// create user
edgeImitator.expectMessageAmount(3);
User newTenantAdmin = new User();
newTenantAdmin.setAuthority(Authority.TENANT_ADMIN);
newTenantAdmin.setTenantId(tenantId);
newTenantAdmin.setEmail("tenantAdmin@thingsboard.org");
newTenantAdmin.setFirstName("Boris");
newTenantAdmin.setLastName("Johnson");
User savedTenantAdmin = createUser(newTenantAdmin, "tenant");
Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user)
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size());
Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size());
Optional<UserUpdateMsg> userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class);
Assert.assertTrue(userUpdateMsgOpt.isPresent());
UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get();
User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
Assert.assertNotNull(userMsg);
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType());
Assert.assertEquals(savedTenantAdmin.getId(), userMsg.getId());
Assert.assertEquals(savedTenantAdmin.getAuthority(), userMsg.getAuthority());
Assert.assertEquals(savedTenantAdmin.getEmail(), userMsg.getEmail());
Assert.assertEquals(savedTenantAdmin.getFirstName(), userMsg.getFirstName());
Assert.assertEquals(savedTenantAdmin.getLastName(), userMsg.getLastName());
Optional<UserCredentialsUpdateMsg> userCredentialsUpdateMsgOpt = edgeImitator.findMessageByType(UserCredentialsUpdateMsg.class);
Assert.assertTrue(userCredentialsUpdateMsgOpt.isPresent());
User newTenantAdmin = buildUser(Authority.TENANT_ADMIN, null);
User savedTenantAdmin = createAndVerifyUserOnEdge(newTenantAdmin);
// update user
edgeImitator.expectMessageAmount(2);
savedTenantAdmin.setLastName("Borisov");
savedTenantAdmin = doPost("/api/user", savedTenantAdmin, User.class);
Assert.assertTrue(edgeImitator.waitForMessages());
userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class);
Assert.assertTrue(userUpdateMsgOpt.isPresent());
userUpdateMsg = userUpdateMsgOpt.get();
userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
Assert.assertNotNull(userMsg);
Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType());
Assert.assertEquals(savedTenantAdmin.getLastName(), userMsg.getLastName());
updateAndVerifyUserLastName(savedTenantAdmin);
// update user credentials
login(savedTenantAdmin.getEmail(), "tenant");
updateAndVerifyUserCredentials(savedTenantAdmin);
loginTenantAdmin();
edgeImitator.expectMessageAmount(1);
ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest();
changePasswordRequest.setCurrentPassword("tenant");
changePasswordRequest.setNewPassword("newTenant");
doPost("/api/auth/changePassword", changePasswordRequest);
Assert.assertTrue(edgeImitator.waitForMessages());
AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg);
UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage;
UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true);
Assert.assertNotNull(userCredentialsMsg);
Assert.assertEquals(savedTenantAdmin.getId(), userCredentialsMsg.getUserId());
Assert.assertTrue(passwordEncoder.matches(changePasswordRequest.getNewPassword(), userCredentialsMsg.getPassword()));
// delete user
deleteAndVerifyUser(savedTenantAdmin);
}
@Test
public void testCreateUpdateDeleteCustomerUser() throws Exception {
// create customer
Customer savedCustomer = createAndAssignCustomerToEdge();
// create user
User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId());
User savedCustomerUser = createAndVerifyUserOnEdge(customerUser);
// update user
updateAndVerifyUserLastName(savedCustomerUser);
// update user credentials
login(savedCustomerUser.getEmail(), "customer");
updateAndVerifyUserCredentials(savedCustomerUser);
loginTenantAdmin();
// delete user
deleteAndVerifyUser(savedCustomerUser);
}
@Test
public void testSendUserToCloudFromEdge() throws Exception {
// create customer
Customer savedCustomer = createAndAssignCustomerToEdge();
// create uplinkMsg with user and userCredentials
UserId userId = new UserId(UUID.randomUUID());
UserCredentialsId userCredentialsId = new UserCredentialsId(UUID.randomUUID());
UplinkMsg uplinkMsg = buildUserUplinkMsg(userId, savedCustomer.getId(), userCredentialsId);
User userFromCloud = verifyMsgOnCloud(uplinkMsg, userId, false);
assertUserCredentialsFlags(userFromCloud, false, false);
// create uplinkMsg with enabled userCredentials
UplinkMsg uplinkMsgWithEnabledCredentials = constructUserCredentialsUplinkMsg(userCredentialsId, userId);
User cloudUserWithCredentials = verifyMsgOnCloud(uplinkMsgWithEnabledCredentials, userId, false);
assertUserCredentialsFlags(cloudUserWithCredentials, true, true);
// create uplinkMsg with user the same email
UserId secondUserId = new UserId(UUID.randomUUID());
UserCredentialsId secondCredentialsId = new UserCredentialsId(UUID.randomUUID());
UplinkMsg uplinkMsgForUserExistingEmail = buildUserUplinkMsg(secondUserId, savedCustomer.getId(), secondCredentialsId);
verifyMsgOnCloud(uplinkMsgForUserExistingEmail, secondUserId, true);
}
@Test
public void testSendUserCredentialsRequestToCloud() throws Exception {
UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder();
UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder();
userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits());
userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits());
testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder);
uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build());
testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder);
edgeImitator.expectResponsesAmount(1);
edgeImitator.expectMessageAmount(1);
doDelete("/api/user/" + savedTenantAdmin.getUuidId())
.andExpect(status().isOk());
edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
Assert.assertTrue(edgeImitator.waitForMessages());
latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof UserUpdateMsg);
userUpdateMsg = (UserUpdateMsg) latestMessage;
Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType());
Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB());
Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB());
UserCredentialsUpdateMsg userCredentialsUpdateMsg = getLatestUserCredentialsUpdateMsg();
UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true);
Assert.assertNotNull(userCredentialsMsg);
Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId());
testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg);
}
@Test
public void testCreateUpdateDeleteCustomerUser() throws Exception {
public void testSendUserDeleteFromEdgeToCloud() throws Exception {
// create customer
Customer savedCustomer = createAndAssignCustomerToEdge();
// create user
User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId());
User savedCustomerUser = createAndVerifyUserOnEdge(customerUser);
// simulate user removal event from edge to cloud
UserUpdateMsg.Builder userUpdateMsg = UserUpdateMsg.newBuilder().setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE)
.setIdMSB(savedCustomerUser.getUuidId().getMostSignificantBits())
.setIdLSB(savedCustomerUser.getUuidId().getLeastSignificantBits());
UplinkMsg uplink = UplinkMsg.newBuilder()
.setUplinkMsgId(EdgeUtils.nextPositiveInt())
.addUserUpdateMsg(userUpdateMsg).build();
testAutoGeneratedCodeByProtobuf(userUpdateMsg);
// expect edge message sent & cloud message response
edgeImitator.expectResponsesAmount(1);
edgeImitator.sendUplinkMsg(uplink);
Assert.assertTrue(edgeImitator.waitForResponses());
loginTenantAdmin();
Awaitility.await().atMost(10, TimeUnit.SECONDS)
.until(() -> {
try {
doGet("/api/user/" + savedCustomerUser.getId(), User.class, status().isNotFound());
return true;
} catch (Throwable ex) {
return false;
}
});
}
private Customer createAndAssignCustomerToEdge() throws Exception {
edgeImitator.expectMessageAmount(1);
Customer customer = new Customer();
customer.setTitle("Edge Customer");
Customer savedCustomer = doPost("/api/customer", customer, Customer.class);
Assert.assertFalse(edgeImitator.waitForMessages(5));
// assign edge to customer
edgeImitator.expectMessageAmount(2);
doPost("/api/customer/" + savedCustomer.getUuidId()
+ "/edge/" + edge.getUuidId(), Edge.class);
doPost("/api/customer/" + savedCustomer.getUuidId() + "/edge/" + edge.getUuidId(), Edge.class);
Assert.assertTrue(edgeImitator.waitForMessages());
// create user
return savedCustomer;
}
private User buildUser(Authority authority, CustomerId customerId) {
User user = new User();
user.setAuthority(authority);
user.setTenantId(tenantId);
user.setCustomerId(customerId);
user.setEmail(authority == Authority.TENANT_ADMIN ? DEFAULT_TENANT_ADMIN_EMAIL : DEFAULT_CUSTOMER_USER_EMAIL);
user.setFirstName(DEFAULT_FIRST_NAME);
user.setLastName(DEFAULT_LAST_NAME);
return user;
}
private User createAndVerifyUserOnEdge(User user) throws Exception {
String password = user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer";
// wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user)
edgeImitator.expectMessageAmount(3);
User customerUser = new User();
customerUser.setAuthority(Authority.CUSTOMER_USER);
customerUser.setTenantId(tenantId);
customerUser.setCustomerId(savedCustomer.getId());
customerUser.setEmail("customerUser@thingsboard.org");
customerUser.setFirstName("John");
customerUser.setLastName("Edwards");
User savedCustomerUser = createUser(customerUser, "customer");
Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user)
User savedUser = createUser(user, password);
Assert.assertTrue(edgeImitator.waitForMessages());
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size());
Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size());
Optional<UserUpdateMsg> userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class);
Assert.assertTrue(userUpdateMsgOpt.isPresent());
UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get();
UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg();
User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
Assert.assertNotNull(userMsg);
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType());
Assert.assertEquals(savedCustomerUser.getId(), userMsg.getId());
Assert.assertEquals(savedCustomerUser.getCustomerId(), userMsg.getCustomerId());
Assert.assertEquals(savedCustomerUser.getAuthority(), userMsg.getAuthority());
Assert.assertEquals(savedCustomerUser.getEmail(), userMsg.getEmail());
Assert.assertEquals(savedCustomerUser.getFirstName(), userMsg.getFirstName());
Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName());
Assert.assertEquals(savedUser.getId(), userMsg.getId());
Assert.assertEquals(savedUser.getCustomerId(), userMsg.getCustomerId());
Assert.assertEquals(savedUser.getAuthority(), userMsg.getAuthority());
Assert.assertEquals(savedUser.getEmail(), userMsg.getEmail());
Assert.assertEquals(savedUser.getFirstName(), userMsg.getFirstName());
Assert.assertEquals(savedUser.getLastName(), userMsg.getLastName());
return savedUser;
}
private void updateAndVerifyUserLastName(User user) throws Exception {
user.setLastName(UPDATED_LAST_NAME);
// update user
edgeImitator.expectMessageAmount(2);
savedCustomerUser.setLastName("Addams");
savedCustomerUser = doPost("/api/user", savedCustomerUser, User.class);
doPost("/api/user", user, User.class);
Assert.assertTrue(edgeImitator.waitForMessages());
userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class);
Assert.assertTrue(userUpdateMsgOpt.isPresent());
userUpdateMsg = userUpdateMsgOpt.get();
userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
Assert.assertNotNull(userMsg);
UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg();
User userFromMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
Assert.assertNotNull(userFromMsg);
Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType());
Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName());
Assert.assertEquals(UPDATED_LAST_NAME, userFromMsg.getLastName());
}
// update user credentials
login(savedCustomerUser.getEmail(), "customer");
private void updateAndVerifyUserCredentials(User user) throws Exception {
String password = user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer";
String newPassword = "new" + password;
edgeImitator.expectMessageAmount(1);
ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest();
changePasswordRequest.setCurrentPassword("customer");
changePasswordRequest.setNewPassword("newCustomer");
changePasswordRequest.setCurrentPassword(password);
changePasswordRequest.setNewPassword(newPassword);
doPost("/api/auth/changePassword", changePasswordRequest);
Assert.assertTrue(edgeImitator.waitForMessages());
AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg);
UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage;
UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true);
Assert.assertNotNull(userCredentialsMsg);
Assert.assertEquals(savedCustomerUser.getId(), userCredentialsMsg.getUserId());
Assert.assertTrue(passwordEncoder.matches(changePasswordRequest.getNewPassword(), userCredentialsMsg.getPassword()));
loginTenantAdmin();
UserCredentialsUpdateMsg msg = getLatestUserCredentialsUpdateMsg();
UserCredentials creds = JacksonUtil.fromString(msg.getEntity(), UserCredentials.class, true);
Assert.assertNotNull(creds);
Assert.assertEquals(user.getId(), creds.getUserId());
Assert.assertTrue(passwordEncoder.matches(newPassword, creds.getPassword()));
}
// delete user
private void deleteAndVerifyUser(User savedTenantAdmin) throws Exception {
edgeImitator.expectMessageAmount(1);
doDelete("/api/user/" + savedCustomerUser.getUuidId())
doDelete("/api/user/" + savedTenantAdmin.getUuidId())
.andExpect(status().isOk());
Assert.assertTrue(edgeImitator.waitForMessages());
latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof UserUpdateMsg);
userUpdateMsg = (UserUpdateMsg) latestMessage;
UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg();
Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType());
Assert.assertEquals(savedCustomerUser.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB());
Assert.assertEquals(savedCustomerUser.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB());
Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB());
Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB());
}
private UplinkMsg buildUserUplinkMsg(UserId userId, CustomerId customerId, UserCredentialsId userCredentialsUuid) {
User customerUser = buildUser(Authority.CUSTOMER_USER, customerId);
customerUser.setId(userId);
UserCredentials userCredentials = buildCredentials(userCredentialsUuid, userId, false);
userCredentials.setActivateToken(StringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH));
UserUpdateMsg userUpdateMsg = EdgeMsgConstructorUtils.constructUserUpdatedMsg(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUser);
UserCredentialsUpdateMsg userCredentialsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials);
return UplinkMsg.newBuilder()
.addUserUpdateMsg(userUpdateMsg)
.addUserCredentialsUpdateMsg(userCredentialsMsg)
.build();
}
@Test
public void testSendUserCredentialsRequestToCloud() throws Exception {
UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder();
UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder();
userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits());
userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits());
testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder);
uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build());
private UserCredentials buildCredentials(UserCredentialsId userCredentialsUuid, UserId userId, boolean enabled) {
UserCredentials userCredentials = new UserCredentials();
testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder);
userCredentials.setId(userCredentialsUuid);
userCredentials.setUserId(userId);
userCredentials.setEnabled(enabled);
userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode());
return userCredentials;
}
private User verifyMsgOnCloud(UplinkMsg uplinkMsg, UserId userId, boolean emailExist) throws Exception {
edgeImitator.expectResponsesAmount(1);
edgeImitator.expectMessageAmount(1);
edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build());
edgeImitator.sendUplinkMsg(uplinkMsg);
Assert.assertTrue(edgeImitator.waitForResponses());
Assert.assertTrue(edgeImitator.waitForMessages());
AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg);
UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage;
UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true);
Assert.assertNotNull(userCredentialsMsg);
Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId());
User userFromCloud = doGet("/api/user/" + userId, User.class);
Assert.assertNotNull(userFromCloud);
if (emailExist) {
Assert.assertNotEquals(DEFAULT_CUSTOMER_USER_EMAIL, userFromCloud.getEmail());
} else {
Assert.assertEquals(DEFAULT_CUSTOMER_USER_EMAIL, userFromCloud.getEmail());
}
return userFromCloud;
}
@Test
public void sendUserCredentialsRequest() throws Exception {
UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder();
UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder();
userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits());
userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits());
testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder);
uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build());
private UplinkMsg constructUserCredentialsUplinkMsg(UserCredentialsId userCredentialsUuid, UserId userId) {
UserCredentials userCredentials = buildCredentials(userCredentialsUuid, userId, true);
userCredentials.setPassword("password");
UserCredentialsUpdateMsg credsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials);
testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder);
return UplinkMsg.newBuilder()
.addUserCredentialsUpdateMsg(credsMsg)
.build();
}
edgeImitator.expectResponsesAmount(1);
edgeImitator.expectMessageAmount(1);
edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
Assert.assertTrue(edgeImitator.waitForMessages());
private void assertUserCredentialsFlags(User user, boolean enabled, boolean activated) {
JsonNode info = user.getAdditionalInfo();
Assert.assertNotNull(info);
Assert.assertEquals(enabled, info.get("userCredentialsEnabled").asBoolean());
Assert.assertEquals(activated, info.get("userActivated").asBoolean());
}
AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg);
UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage;
UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true);
Assert.assertNotNull(userCredentialsMsg);
Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId());
private UserUpdateMsg getLatestUserUpdateMsg() {
Optional<UserUpdateMsg> opt = edgeImitator.findMessageByType(UserUpdateMsg.class);
Assert.assertTrue(opt.isPresent());
return opt.get();
}
testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg);
private UserCredentialsUpdateMsg getLatestUserCredentialsUpdateMsg() {
Optional<UserCredentialsUpdateMsg> opt = edgeImitator.findMessageByType(UserCredentialsUpdateMsg.class);
Assert.assertTrue(opt.isPresent());
return opt.get();
}
}

6
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -48,6 +48,8 @@ public interface UserService extends EntityDaoService {
User saveUser(TenantId tenantId, User user);
User saveUser(TenantId tenantId, User user, boolean doValidate);
UserCredentials findUserCredentialsByUserId(TenantId tenantId, UserId userId);
UserCredentials findUserCredentialsByActivateToken(TenantId tenantId, String activateToken);
@ -56,6 +58,8 @@ public interface UserService extends EntityDaoService {
UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials);
UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials, boolean doValidate);
UserCredentials activateUserCredentials(TenantId tenantId, String activateToken, String password);
UserCredentials requestPasswordReset(TenantId tenantId, String email);
@ -70,6 +74,8 @@ public interface UserService extends EntityDaoService {
UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials);
void deleteUserCredentials(TenantId tenantId, UserCredentials userCredentials);
void deleteUser(TenantId tenantId, User user);
PageData<User> findUsersByTenantId(TenantId tenantId, PageLink pageLink);

3
common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java

@ -41,6 +41,7 @@ public class DataConstants {
public static final String EDGE_ID = "edgeId";
public static final String DEVICE_ID = "deviceId";
public static final String GATEWAY_PARAMETER = "gateway";
public static final String CF_NAME_METADATA_KEY = "calculatedFieldName";
public static final String OVERWRITE_ACTIVITY_TIME_PARAMETER = "overwriteActivityTime";
public static final String COAP_TRANSPORT_NAME = "COAP";
@ -105,6 +106,8 @@ public class DataConstants {
public static final String RPC_FAILED = "RPC_FAILED";
public static final String RPC_DELETED = "RPC_DELETED";
public static final String REEVALUATION_MSG = "REEVALUATION_MSG";
public static final String DEFAULT_SECRET_KEY = "";
public static final String SECRET_KEY_FIELD_NAME = "secretKey";
public static final String DURATION_MS_FIELD_NAME = "durationMs";

2
common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java

@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoNullChar;
import org.thingsboard.server.common.data.validation.NoXss;
import java.io.Serial;
@ -64,6 +65,7 @@ public final class AiModel extends BaseData<AiModelId> implements HasTenantId, H
@NotBlank
@NoNullChar
@Length(min = 1, max = 255)
@NoXss
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,

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

@ -24,6 +24,7 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
public class AttributesImmediateOutputStrategy implements AttributesOutputStrategy {
private boolean sendAttributesUpdatedNotification;
private boolean updateAttributesOnlyOnValueChange;
private boolean saveAttribute;

2
common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.id.OAuth2ClientId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoXss;
import java.util.List;
@ -42,6 +43,7 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo<OAuth2ClientId> imp
private TenantId tenantId;
@Schema(description = "Oauth2 client title")
@NotBlank
@NoXss
@Length(fieldName = "title", max = 100, message = "cannot be longer than 100 chars")
private String title;
@Schema(description = "Config for mapping OAuth2 log in response to platform entities", requiredMode = Schema.RequiredMode.REQUIRED)

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

@ -65,7 +65,7 @@ public class AggIntervalTest {
@ParameterizedTest
@MethodSource("intervals")
void testGetStartAndEndWithoutOffset(LongFunction<AggInterval> intervalCreator) {
void testGetStartAndEndWithoutOffset(LongFunction<AggInterval> intervalCreator, long expectedDuration) {
AggInterval interval = intervalCreator.apply(0L);
ZonedDateTime dateTime = ZonedDateTime.of(
@ -76,7 +76,7 @@ public class AggIntervalTest {
long endTs = interval.getDateTimeIntervalEndTs(dateTime);
assertThat(endTs).isGreaterThan(startTs);
assertThat(endTs - startTs).isEqualTo(interval.getCurrentIntervalDurationMillis());
assertThat(endTs - startTs).isEqualTo(expectedDuration);
}
@ParameterizedTest
@ -88,7 +88,7 @@ public class AggIntervalTest {
ZonedDateTime dateTime = ZonedDateTime.of(
// 2025.11.11 11:20:00 - chosen so 15m offset shifts into a new interval
2025, 11, 11, 11, 20, 0, 0, ZoneId.of(TZ)
2025, 6, 6, 6, 20, 0, 0, ZoneId.of(TZ)
);
long startWithOffsetTs = intervalWithOffset.getDateTimeIntervalStartTs(dateTime);
@ -103,14 +103,14 @@ public class AggIntervalTest {
private static Stream<Arguments> intervals() {
return Stream.of(
Arguments.of((LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)))
Arguments.of((LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset), TimeUnit.HOURS.toMillis(1)),
Arguments.of((LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset), TimeUnit.DAYS.toMillis(1)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset), TimeUnit.DAYS.toMillis(7)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset), TimeUnit.DAYS.toMillis(7)),
Arguments.of((LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset), TimeUnit.DAYS.toMillis(30)),
Arguments.of((LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset), TimeUnit.DAYS.toMillis(92) + TimeUnit.HOURS.toMillis(1)),// Includes DST fallback (2025-10-26), so duration = 92 days + 1 hour(expected for Europe/Kyiv timezone).
Arguments.of((LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset), TimeUnit.DAYS.toMillis(365)),
Arguments.of((LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)), TimeUnit.HOURS.toMillis(4))
);
}

2
common/edge-api/src/main/proto/edge.proto

@ -449,6 +449,8 @@ message UplinkMsg {
repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25;
repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26;
repeated AiModelUpdateMsg aiModelUpdateMsg = 27;
repeated UserUpdateMsg userUpdateMsg = 28;
repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 29;
}
message UplinkResponseMsg {

38
dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java

@ -96,8 +96,9 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
public static final String USER_PASSWORD_HISTORY = "userPasswordHistory";
private static final int DEFAULT_TOKEN_LENGTH = 30;
public static final int DEFAULT_TOKEN_LENGTH = 30;
public static final String INCORRECT_USER_ID = "Incorrect userId ";
public static final String INCORRECT_USER_CREDENTIALS_ID = "Incorrect userCredentialsId ";
public static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
@Value("${security.user_login_case_sensitive:true}")
@ -173,12 +174,23 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
@Override
@Transactional
public User saveUser(TenantId tenantId, User user) {
return saveEntity(user, () -> doSaveUser(tenantId, user));
return saveUser(tenantId, user, true);
}
private User doSaveUser(TenantId tenantId, User user) {
@Override
@Transactional
public User saveUser(TenantId tenantId, User user, boolean doValidate) {
return saveEntity(user, () -> doSaveUser(tenantId, user, doValidate));
}
private User doSaveUser(TenantId tenantId, User user, boolean doValidate) {
log.trace("Executing saveUser [{}]", user);
User oldUser = userValidator.validate(user, User::getTenantId);
User oldUser = null;
if (doValidate) {
oldUser = userValidator.validate(user, User::getTenantId);
} else if (user.getId() != null) {
oldUser = findUserById(user.getTenantId(), user.getId());
}
if (!userLoginCaseSensitive) {
user.setEmail(user.getEmail().toLowerCase());
}
@ -233,8 +245,15 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
@Override
public UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials) {
return saveUserCredentials(tenantId, userCredentials, true);
}
@Override
public UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials, boolean doValidate) {
log.trace("Executing saveUserCredentials [{}]", userCredentials);
userCredentialsValidator.validate(userCredentials, data -> tenantId);
if (doValidate) {
userCredentialsValidator.validate(userCredentials, data -> tenantId);
}
UserCredentials result = userCredentialsDao.save(tenantId, userCredentials);
eventPublisher.publishEvent(ActionEntityEvent.builder()
.tenantId(tenantId)
@ -337,6 +356,15 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
return result;
}
@Override
public void deleteUserCredentials(TenantId tenantId, UserCredentials userCredentials) {
Objects.requireNonNull(userCredentials, "UserCredentials is null");
UserCredentialsId userCredentialsId = userCredentials.getId();
log.trace("[{}] Executing deleteUserCredentials [{}]", tenantId, userCredentialsId);
validateId(userCredentialsId, id -> INCORRECT_USER_CREDENTIALS_ID + id);
userCredentialsDao.removeById(tenantId, userCredentialsId.getId());
}
@Override
@Transactional
public void deleteUser(TenantId tenantId, User user) {

4
ui-ngx/src/app/core/auth/auth.service.ts

@ -168,8 +168,8 @@ export class AuthService {
));
}
public getUserPasswordPolicy() {
return this.http.get<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptions());
public getUserPasswordPolicy(config?: RequestConfig) {
return this.http.get<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptionsFromConfig(config));
}
public activateByEmailCode(emailCode: string): Observable<LoginResponse> {

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

@ -168,9 +168,8 @@ export class DashboardUtilsService {
dashboard.configuration.filters = {};
}
if (isUndefined(dashboard.configuration.timewindow)) {
dashboard.configuration.timewindow = this.timeService.defaultTimewindow(true);
}
dashboard.configuration.timewindow = initModelFromDefaultTimewindow(dashboard.configuration.timewindow,
false, false, this.timeService, true, true);
if (isUndefined(dashboard.configuration.settings)) {
dashboard.configuration.settings = {};
dashboard.configuration.settings.stateControllerId = 'entity';

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

@ -67,7 +67,7 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
debugSettings: [],
entityId: this.fb.group({
entityType: this.fb.control<EntityType | AliasEntityType | null>(null, Validators.required),
id: ['', Validators.required],
id: [null as null | string, Validators.required],
}),
configuration: this.fb.group({
arguments: this.fb.control({}),
@ -101,7 +101,6 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
private destroyRef: DestroyRef,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.observeIsLoading();
this.applyDialogData();
}
@ -178,19 +177,6 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, entityId, ...value }, {emitEvent: false});
}
private observeIsLoading(): void {
this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => {
if (loading) {
this.fieldFormGroup.disable({emitEvent: false});
} else {
this.fieldFormGroup.enable({emitEvent: false});
if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty();
}
}
});
}
onTestScript(expression: string): Observable<string> {
const calculatedFieldId = this.data.value?.id?.id;
if (calculatedFieldId) {

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

@ -152,7 +152,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
this.cellActionDescriptors.push(
{
name: this.translate.instant('notification.copy-template'),
name: this.translate.instant('alarm-rule.copy'),
icon: 'content_copy',
isEnabled: () => true,
onAction: ($event, entity) => this.copyCalculatedField(entity)

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

@ -20,7 +20,7 @@ import {
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormControl,
ValidationErrors,
Validator
} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
@ -130,10 +130,10 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
}
public conditionSet() {
return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters) || !this.required;
return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters);
}
public validate(c: UntypedFormControl) {
public validate(): ValidationErrors | null {
return this.conditionSet() ? null : {
alarmRuleCondition: {
valid: false,
@ -166,11 +166,11 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali
private updateConditionInfo() {
this.alarmRuleConditionFormGroup.patchValue(
{
this.modelValue ? {
type: this.modelValue?.type,
expression: this.modelValue?.expression,
schedule: this.modelValue?.schedule,
}, {emitEvent: false}
} : null, {emitEvent: false}
);
this.updateScheduleText();
this.updateSpecText();

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

@ -20,8 +20,9 @@ import {
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormControl,
Validator
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { isDefinedAndNotNull } from '@core/utils';
@ -76,7 +77,7 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid
private modelValue: AlarmRule;
alarmRuleFormGroup = this.fb.group({
condition: this.fb.control<AlarmRuleCondition | null>(null),
condition: this.fb.control<AlarmRuleCondition | null>(null, Validators.required),
alarmDetails: [null],
dashboardId: [null]
});
@ -113,14 +114,12 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid
}
writeValue(value: AlarmRule): void {
if (value) {
this.modelValue = value;
const model = this.modelValue ? {
...this.modelValue,
dashboardId: this.modelValue.dashboardId?.id
} : null;
this.alarmRuleFormGroup.patchValue(model, {emitEvent: false});
}
this.modelValue = value;
const model = this.modelValue ? {
...this.modelValue,
dashboardId: this.modelValue.dashboardId?.id
} : null;
this.alarmRuleFormGroup.patchValue(model, {emitEvent: false});
}
public openEditDetailsDialog($event: Event) {
@ -142,7 +141,7 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid
});
}
public validate(c: UntypedFormControl) {
public validate(): ValidationErrors | null {
return (!this.required && !this.modelValue || this.alarmRuleFormGroup.valid) ? null : {
alarmRule: {
valid: false,

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

@ -23,7 +23,7 @@ import {
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormControl,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
@ -159,8 +159,8 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida
return null;
}
public validate(c: UntypedFormControl) {
return this.createAlarmRulesFormArray().length ? null : {
public validate(): ValidationErrors | null {
return this.createAlarmRulesFormGroup.valid && this.createAlarmRulesFormArray().length > 0 ? null : {
createAlarmRules: {
valid: false,
},

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

@ -35,7 +35,10 @@
</div>
<mat-form-field class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>api-key.description</mat-label>
<textarea #input cdkTextareaAutosize matInput formControlName="description" rows="2" maxLength="255"></textarea>
<textarea cdkTextareaAutosize matInput formControlName="description" rows="2" maxLength="255"></textarea>
<mat-error *ngIf="apiKeyForm.get('description').hasError('required')">
{{ 'asset.description-required' | translate }}
</mat-error>
</mat-form-field>
<mat-slide-toggle class="mat-slide" formControlName="enabled">
{{ 'api-key.enable' | translate }}

4
ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html

@ -96,6 +96,10 @@
<ng-template #executeCommand>
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>device.connectivity.execute-following-command</div>
<div class="tb-form-hint tb-primary-fill insecure-url-warning flex gap-2" [class.hidden]="secureUrl">
<mat-icon class="tb-mat-24">warning</mat-icon>
<span>{{ 'api-key.generated-api-key-insecure-url' | translate }}</span>
</div>
<tb-markdown usePlainMarkdown containerClass="tb-command-code" [data]='createMarkDownCommand(apiKeyCommand)'></tb-markdown>
</div>
</ng-template>

17
ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss

@ -15,9 +15,8 @@
*/
@import '../../src/scss/constants';
:host{
:host {
display: grid;
width: 500px;
height: 100%;
max-width: 100%;
max-height: 100vh;
@ -26,6 +25,20 @@
.tb-install-instruction-text {
min-height: 42px;
}
.insecure-url-warning {
.mat-icon {
color: #FAA405;
}
}
@media #{$mat-sm} {
width: 470px;
}
@media #{$mat-gt-sm} {
width: 720px;
}
}
:host ::ng-deep {

5
ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts

@ -34,7 +34,10 @@ export interface ApiKeyGeneratedDialogData {
})
export class ApiKeyGeneratedDialogComponent extends DialogComponent<ApiKeyGeneratedDialogComponent, void> {
apiKeyCommand = userInfoCommand(this.data.apiKey.value);
private baseUrl = window.location.origin;
apiKeyCommand = userInfoCommand(this.baseUrl, this.data.apiKey.value);
secureUrl = this.baseUrl.startsWith('https');
selectedTab: number;
constructor(protected store: Store<AppState>,

5
ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html

@ -21,7 +21,10 @@
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>api-key.description</mat-label>
<textarea #input cdkTextareaAutosize matInput [formControl]="descriptionFormControl" rows="2" maxLength="255"></textarea>
<mat-hint align="end">{{input.value?.length || 0}}/255</mat-hint>
<mat-error *ngIf="descriptionFormControl.hasError('required')">
{{ 'asset.description-required' | translate }}
</mat-error>
<mat-hint align="end">{{ input.value?.length || 0 }}/255</mat-hint>
</mat-form-field>
<div class="tb-edit-api-key-description-panel-buttons">
<button mat-button

4
ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts

@ -16,7 +16,7 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { FormBuilder, Validators } from '@angular/forms';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { ApiKeyService } from '@core/http/api-key.service';
@ -37,7 +37,7 @@ export class EditApiKeyDescriptionPanelComponent implements OnInit {
@Output()
descriptionApplied = new EventEmitter<string>();
descriptionFormControl = this.fb.control<string>(null);
descriptionFormControl = this.fb.control<string>(null, Validators.required);
constructor(private fb: FormBuilder,
private popover: TbPopoverComponent<EditApiKeyDescriptionPanelComponent>,

2
ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts

@ -162,7 +162,7 @@ export class AuditLogFilterComponent implements OnInit, ControlValueAccessor {
reset() {
if (this.initialAuditLogFilter) {
if (!auditLogFilterEquals(this.auditLogFilter, this.initialAuditLogFilter)) {
if (!auditLogFilterEquals(this.auditLogFilterForm.value, this.initialAuditLogFilter)) {
this.updateAuditLogFilterForm(this.initialAuditLogFilter);
this.auditLogFilterForm.markAsDirty();
}

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

@ -95,40 +95,37 @@
</mat-panel-title>
</mat-expansion-panel-header>
@if (outputForm.get('strategy.type').value === OutputStrategyType.IMMEDIATE) {
@if (outputForm.get('type').value === OutputType.Timeseries) {
<div class="tb-form-row flex-1">
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>calculated-fields.output-strategy.processing-parameters</div>
@if (outputForm.get('type').value === OutputType.Timeseries) {
<mat-slide-toggle class="mat-slide" formControlName="saveTimeSeries">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.output-strategy.hint.save-time-series' | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.save-time-series</div>
</div>
</mat-slide-toggle>
</div>
<div class="tb-form-row flex-1">
<mat-slide-toggle class="mat-slide" formControlName="saveLatest">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.output-strategy.hint.save-latest-values' | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.save-latest-values</div>
</div>
</mat-slide-toggle>
</div>
} @else {
<div class="tb-form-row flex-1">
} @else {
<mat-slide-toggle class="mat-slide" formControlName="saveAttribute">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.output-strategy.hint.save-database' | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.save-database</div>
</div>
</mat-slide-toggle>
</div>
}
<div class="tb-form-row flex-1">
}
<mat-slide-toggle class="mat-slide" formControlName="sendWsUpdate">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.output-strategy.hint.send-web-sockets' | translate }}">
<div tb-hint-tooltip-icon="{{ (outputForm.get('type').value === OutputType.Attribute
? 'calculated-fields.output-strategy.hint.send-web-sockets-attribute'
: 'calculated-fields.output-strategy.hint.send-web-sockets-time-series') | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.send-web-sockets</div>
</div>
</mat-slide-toggle>
</div>
<div class="tb-form-row flex-1">
<mat-slide-toggle class="mat-slide" formControlName="processCfs">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.output-strategy.hint.save-calculated-fields' | translate }}">
<div tb-hint-tooltip-icon="{{ (outputForm.get('type').value === OutputType.Attribute
? 'calculated-fields.output-strategy.hint.save-calculated-fields-attribute'
: 'calculated-fields.output-strategy.hint.save-calculated-fields-time-series') | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.save-calculated-fields</div>
</div>
</mat-slide-toggle>
@ -137,9 +134,16 @@
<div class="tb-form-row flex-1">
<mat-slide-toggle class="mat-slide" formControlName="updateAttributesOnlyOnValueChange">
<div tb-hint-tooltip-icon="{{ (outputForm.get('strategy.updateAttributesOnlyOnValueChange').value
? 'calculated-fields.output-strategy.hint.update-attributes-only-on-value-change-enabled'
: 'calculated-fields.output-strategy.hint.update-attributes-only-on-value-change') | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.update-attributes-only-on-value-change</div>
? 'calculated-fields.output-strategy.hint.update-attribute-only-on-value-change-enabled'
: 'calculated-fields.output-strategy.hint.update-attribute-only-on-value-change') | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.update-attribute-only-on-value-change</div>
</div>
</mat-slide-toggle>
</div>
<div class="tb-form-row flex-1">
<mat-slide-toggle class="mat-slide" formControlName="sendAttributesUpdatedNotification">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.output-strategy.hint.send-attributes-updated-notification' | translate }}">
<div translate tbTruncateWithTooltip>calculated-fields.output-strategy.send-attributes-updated-notification</div>
</div>
</mat-slide-toggle>
</div>

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

@ -104,6 +104,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
sendWsUpdate: [true],
processCfs: [true],
updateAttributesOnlyOnValueChange: [true],
sendAttributesUpdatedNotification: [false],
useCustomTtl: [false],
ttl: [0]
})
@ -230,6 +231,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
if (outputType === OutputType.Attribute) {
this.outputForm.get('strategy.saveAttribute').enable({emitEvent: false});
this.outputForm.get('strategy.updateAttributesOnlyOnValueChange').enable({emitEvent: false});
this.outputForm.get('strategy.sendAttributesUpdatedNotification').enable({emitEvent: false});
} else {
this.outputForm.get('strategy.saveTimeSeries').enable({emitEvent: false});
this.outputForm.get('strategy.saveLatest').enable({emitEvent: false});

7
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts

@ -34,7 +34,7 @@ import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { AuthUser } from '@shared/models/user.model';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Timewindow, toHistoryTimewindow } from '@shared/models/time/time.models';
import { initModelFromDefaultTimewindow, Timewindow, toHistoryTimewindow } from '@shared/models/time/time.models';
import { TimeService } from '@core/services/time.service';
import { GridsterComponent, GridsterConfig, GridType } from 'angular-gridster2';
import {
@ -223,9 +223,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
ngOnInit(): void {
this.dashboardWidgets.parentDashboard = this.parentDashboard;
this.dashboardWidgets.popoverComponent = this.popoverComponent;
if (!this.dashboardTimewindow) {
this.dashboardTimewindow = this.timeService.defaultTimewindow(true);
}
this.dashboardTimewindow = initModelFromDefaultTimewindow(this.dashboardTimewindow,
false, false, this.timeService, true, true);
this.gridsterOpts = {
gridType: this.gridType || GridType.ScrollVertical,
keepFixedHeightInMobile: true,

6
ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts

@ -943,15 +943,15 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
entityLabelColumnTitle
} = this.modelValue.config.settings;
const displayEntitiesArray = [];
if (isDefined(displayEntityName)) {
if (displayEntityName) {
const displayName = entityNameColumnTitle ? entityNameColumnTitle : 'entityName';
displayEntitiesArray.push({name: displayName, label: displayName});
}
if (isDefined(displayEntityLabel)) {
if (displayEntityLabel) {
const displayLabel = entityLabelColumnTitle ? entityLabelColumnTitle : 'entityLabel';
displayEntitiesArray.push({name: displayLabel, label: displayLabel});
}
if (isDefined(displayEntityType)) {
if (displayEntityType) {
displayEntitiesArray.push({name: 'entityType', label: 'entityType'});
}
configuredColumns.push(...displayEntitiesArray, ...this.keysToCellClickColumns(this.modelValue.config.datasources[0].dataKeys));

14
ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html

@ -136,7 +136,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row tb-standard-fields no-border no-padding column-xs" *ngIf="isCustomProvider()">
<div class="grid gt-xs:grid-cols-2 gt-xs:gap-4" *ngIf="isCustomProvider()">
<mat-form-field class="flex" floatLabel="always">
<mat-label translate>admin.oauth2.login-button-label</mat-label>
<input matInput formControlName="loginButtonLabel"
@ -145,10 +145,14 @@
{{ 'admin.oauth2.login-button-label-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="flex">
<mat-label translate>admin.oauth2.login-button-icon</mat-label>
<input matInput formControlName="loginButtonIcon">
</mat-form-field>
<div class="tb-form-row justify-between" style="height: auto; margin-bottom: 22px;">
<div translate>admin.oauth2.login-button-icon</div>
<tb-material-icon-select asBoxInput
iconClearButton
allowedCustomIcon
formControlName="loginButtonIcon">
</tb-material-icon-select>
</div>
</div>
<section formGroupName="mapperConfig" style="margin-bottom: 22px">
<div class="tb-form-row no-border no-padding">

4
ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts

@ -85,8 +85,8 @@ export class DomainTableConfigResolver {
this.config.loadEntity = id => this.domainService.getDomainInfoById(id.id);
this.config.saveEntity = (domain, originalDomain) => {
const clientsIds = domain.oauth2ClientInfos as Array<string> || [];
const shouldUpdateClients = domain.id && !isEqual(domain.oauth2ClientInfos?.sort(),
originalDomain.oauth2ClientInfos?.map(info => info.id ? info.id.id : info).sort());
const shouldUpdateClients = domain.id && !isEqual(domain.oauth2ClientInfos,
originalDomain.oauth2ClientInfos?.map(info => info.id ? info.id.id : info));
delete domain.oauth2ClientInfos;
return this.domainService.saveDomain(domain, domain.id ? null : clientsIds).pipe(

10
ui-ngx/src/app/modules/home/pages/security/security.component.html

@ -54,7 +54,7 @@
<mat-label translate>profile.current-password</mat-label>
<input matInput type="password" name="current-password" formControlName="currentPassword" autocomplete="current-password"/>
<tb-toggle-password [class.!hidden]="!changePassword.get('currentPassword').dirty && !changePassword.get('currentPassword').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('currentPassword').hasError('differencePassword')">
<mat-error *ngIf="changePassword.get('currentPassword').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.incorrect-password-try-again' | translate }}
</mat-error>
</mat-form-field>
@ -66,13 +66,13 @@
<mat-error *ngIf="changePassword.get('newPassword').errors
&& !changePassword.get('newPassword').hasError('alreadyUsed')
&& !changePassword.get('newPassword').hasError('hasWhitespaces')
&& !changePassword.get('newPassword').hasError('samePassword')">
&& !changePassword.get('newPassword').hasError('passwordSameAsOld')">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('alreadyUsed')">
{{ changePassword.get('newPassword').getError('alreadyUsed') }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('samePassword')">
<mat-error *ngIf="changePassword.get('newPassword').hasError('passwordSameAsOld')">
{{ 'security.password-requirement.password-should-difference' | translate }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('hasWhitespaces')">
@ -86,7 +86,7 @@
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" name="new-password" formControlName="newPassword2" autocomplete="new-password" required/>
<tb-toggle-password [class.!hidden]="!changePassword.get('newPassword2').dirty && !changePassword.get('newPassword2').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('newPassword2').hasError('differencePassword')">
<mat-error *ngIf="changePassword.get('newPassword2').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
@ -148,7 +148,7 @@
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async)">
[disabled]="(isLoading$ | async) || (!changePassword.valid && changePassword.touched)">
{{ 'profile.change-password' | translate }}
</button>
</div>

82
ui-ngx/src/app/modules/home/pages/security/security.component.ts

@ -21,9 +21,9 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
AbstractControl,
FormGroupDirective,
UntypedFormBuilder,
UntypedFormGroup, FormGroupDirective,
NgForm,
UntypedFormGroup,
ValidationErrors,
ValidatorFn,
Validators
@ -48,7 +48,7 @@ import {
import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map';
import { takeUntil, tap } from 'rxjs/operators';
import { Observable, of, Subject } from 'rxjs';
import { isDefinedAndNotNull, isEqual } from '@core/utils';
import { isDefinedAndNotNull } from '@core/utils';
import { AuthService } from '@core/auth/auth.service';
import { UserPasswordPolicy } from '@shared/models/settings.models';
import { MatCheckboxChange } from '@angular/material/checkbox';
@ -56,6 +56,7 @@ import {
ApiKeysTableDialogComponent,
ApiKeysTableDialogData
} from '@home/components/api-key/api-keys-table-dialog.component';
import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models';
@Component({
selector: 'tb-security',
@ -168,7 +169,12 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
this.changePassword = this.fb.group({
currentPassword: [''],
newPassword: ['', Validators.required],
newPassword2: ['', this.samePasswordValidation(false, 'newPassword')]
newPassword2: ['']
}, {
validators: [
this.passwordNotSameAsOld(),
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
}
@ -176,64 +182,36 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro
this.authService.getUserPasswordPolicy().subscribe(policy => {
this.passwordPolicy = policy;
this.changePassword.get('newPassword').setValidators([
this.passwordStrengthValidator(),
this.samePasswordValidation(true, 'currentPassword'),
passwordStrengthValidator(this.passwordPolicy),
Validators.required
]);
this.changePassword.get('newPassword').updateValueAndValidity({emitEvent: false});
});
}
private passwordStrengthValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value;
const errors: any = {};
passwordNotSameAsOld(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const currentPassControl = group.get('currentPassword');
const newPassControl = group.get('newPassword');
if (this.passwordPolicy.minimumUppercaseLetters > 0 &&
!new RegExp(`(?:.*?[A-Z]){${this.passwordPolicy.minimumUppercaseLetters}}`).test(value)) {
errors.notUpperCase = true;
}
if (this.passwordPolicy.minimumLowercaseLetters > 0 &&
!new RegExp(`(?:.*?[a-z]){${this.passwordPolicy.minimumLowercaseLetters}}`).test(value)) {
errors.notLowerCase = true;
}
if (this.passwordPolicy.minimumDigits > 0
&& !new RegExp(`(?:.*?\\d){${this.passwordPolicy.minimumDigits}}`).test(value)) {
errors.notNumeric = true;
}
if (this.passwordPolicy.minimumSpecialCharacters > 0 &&
!new RegExp(`(?:.*?[\\W_]){${this.passwordPolicy.minimumSpecialCharacters}}`).test(value)) {
errors.notSpecial = true;
}
if (!this.passwordPolicy.allowWhitespaces && /\s/.test(value)) {
errors.hasWhitespaces = true;
}
if (this.passwordPolicy.minimumLength > 0 && value.length < this.passwordPolicy.minimumLength) {
errors.minLength = true;
}
const current = currentPassControl?.value ?? '';
const newPass = newPassControl?.value ?? '';
if (!value.length || this.passwordPolicy.maximumLength > 0 && value.length > this.passwordPolicy.maximumLength) {
errors.maxLength = true;
}
return isEqual(errors, {}) ? null : errors;
};
}
private samePasswordValidation(isSame: boolean, key: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value;
const keyValue = control.parent?.value[key];
if (isSame) {
return value === keyValue ? {samePassword: true} : null;
if (current && newPass && current === newPass) {
newPassControl?.setErrors({
...newPassControl.errors,
passwordSameAsOld: true
});
return { passwordSameAsOld: true };
} else {
const currentErrors = newPassControl?.errors;
if (currentErrors?.passwordSameAsOld) {
const { passwordSameAsOld, ...rest } = currentErrors;
newPassControl.setErrors(Object.keys(rest).length ? rest : null);
}
return null;
}
return value !== keyValue ? {differencePassword: true} : null;
};
}

34
ui-ngx/src/app/modules/login/login-routing.module.ts

@ -14,8 +14,8 @@
/// limitations under the License.
///
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { inject, NgModule } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn, Router, RouterModule, RouterStateSnapshot, Routes } from '@angular/router';
import { LoginComponent } from './pages/login/login.component';
import { AuthGuard } from '@core/guards/auth.guard';
@ -26,6 +26,21 @@ import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-fact
import { Authority } from '@shared/models/authority.enum';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.component';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthService } from '@core/auth/auth.service';
import { UserPasswordPolicy } from '@shared/models/settings.models';
const passwordPolicyResolver: ResolveFn<UserPasswordPolicy> = (route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
router = inject(Router),
authService = inject(AuthService)) => {
return authService.getUserPasswordPolicy({ignoreErrors: true}).pipe(
catchError(() => {
return of({} as UserPasswordPolicy);
})
);
};
const routes: Routes = [
{
@ -53,7 +68,10 @@ const routes: Routes = [
title: 'login.reset-password',
module: 'public'
},
canActivate: [AuthGuard]
canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
},
{
path: 'login/resetExpiredPassword',
@ -63,7 +81,10 @@ const routes: Routes = [
module: 'public',
expiredPassword: true
},
canActivate: [AuthGuard]
canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
},
{
path: 'login/createPassword',
@ -72,7 +93,10 @@ const routes: Routes = [
title: 'login.create-password',
module: 'public'
},
canActivate: [AuthGuard]
canActivate: [AuthGuard],
resolve: {
passwordPolicy: passwordPolicyResolver
}
},
{
path: 'login/mfa',

22
ui-ngx/src/app/modules/login/pages/login/create-password.component.html

@ -32,15 +32,28 @@
<span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>common.password</mat-label>
<input matInput type="password" autofocus formControlName="password"/>
<input matInput
type="password"
autofocus
cdkOverlayOrigin
#passwordTrigger="cdkOverlayOrigin"
(focus)="passwordTooltip.onFocus()"
(blur)="passwordTooltip.onBlur()"
formControlName="newPassword"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="this.createPassword.get('newPassword').errors">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.password-again</mat-label>
<input matInput type="password" formControlName="password2"/>
<input matInput type="password" formControlName="newPassword2"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="createPassword.get('newPassword2').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
<div class="flex flex-col items-center justify-start gap-4 gt-xs:flex-row gt-xs:items-start gt-xs:justify-center">
<button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)">
@ -57,3 +70,8 @@
</mat-card-content>
</mat-card>
</div>
<tb-password-requirements-tooltip #passwordTooltip
[passwordControl]="createPassword.get('newPassword')"
[passwordPolicy]="passwordPolicy"
[trigger]="passwordTrigger">
</tb-password-requirements-tooltip>

60
ui-ngx/src/app/modules/login/pages/login/create-password.component.ts

@ -14,61 +14,55 @@
/// limitations under the License.
///
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { AuthService } from '@core/auth/auth.service';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { UntypedFormBuilder } from '@angular/forms';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { UserPasswordPolicy } from '@shared/models/settings.models';
import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models';
@Component({
selector: 'tb-create-password',
templateUrl: './create-password.component.html',
styleUrls: ['./create-password.component.scss']
})
export class CreatePasswordComponent extends PageComponent implements OnInit, OnDestroy {
export class CreatePasswordComponent extends PageComponent {
activateToken = '';
sub: Subscription;
passwordPolicy: UserPasswordPolicy;
createPassword: FormGroup;
createPassword = this.fb.group({
password: [''],
password2: ['']
});
private activateToken: string;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
constructor(private route: ActivatedRoute,
private authService: AuthService,
private translate: TranslateService,
public fb: UntypedFormBuilder) {
super(store);
}
private fb: FormBuilder) {
super();
this.activateToken = this.route.snapshot.queryParams['activateToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
ngOnInit() {
this.sub = this.route
.queryParams
.subscribe(params => {
this.activateToken = params.activateToken || '';
});
this.buildCreatePasswordForm();
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.sub.unsubscribe();
private buildCreatePasswordForm() {
this.createPassword = this.fb.group({
newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]],
newPassword2: ['']
}, {
validators: [
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
}
onCreatePassword() {
if (this.createPassword.get('password').value !== this.createPassword.get('password2').value) {
this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'),
type: 'error' }));
if (this.createPassword.invalid) {
this.createPassword.markAllAsTouched();
} else {
this.authService.activate(
this.activateToken,
this.createPassword.get('password').value, true).subscribe();
this.createPassword.get('newPassword').value, true).subscribe();
}
}
}

40
ui-ngx/src/app/modules/login/pages/login/login.component.html

@ -28,19 +28,35 @@
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<div tb-toast class="layout-padding flex flex-col">
<span style="height: 50px;"></span>
<div class="oauth-container tb-default flex flex-col gap-4" *ngIf="oauth2Clients?.length">
<ng-container *ngFor="let oauth2Client of oauth2Clients">
<a mat-raised-button class="login-with-button" href="{{ getOAuth2Uri(oauth2Client) }}">
<mat-icon class="icon" svgIcon="{{ oauth2Client.icon }}"></mat-icon>
{{ 'login.login-with' | translate: {name: oauth2Client.name} }}
</a>
</ng-container>
<div class="container-divider">
<div class="line"><mat-divider></mat-divider></div>
<div class="text mat-typography">{{ "login.or" | translate | uppercase }}</div>
<div class="line"><mat-divider></mat-divider></div>
@if(oauth2Clients?.length) {
<div class="oauth-container tb-default flex flex-col gap-4">
@if(oauth2Clients.length === 1) {
<a mat-stroked-button class="login-with-button" [href]=getOAuth2Uri(oauth2Clients[0])>
<tb-icon matButtonIcon class="tb-mat-20">{{ oauth2Clients[0].icon }}</tb-icon>
{{ 'login.login-with' | translate: {name: oauth2Clients[0].name} }}
</a>
} @else {
<div class="login-button-container flex flex-wrap justify-center gap-4">
@for(oauth2Client of oauth2Clients; track oauth2Client.url) {
<a mat-stroked-button class="login-with-button multiple"
[matTooltip]="('login.login-with' | translate: {name: oauth2Client.name})"
[href]=getOAuth2Uri(oauth2Client)>
@if(oauth2Client.icon) {
<tb-icon class="tb-mat-20">{{ oauth2Client.icon }}</tb-icon>
} @else {
<span>{{ oauth2Client.name }}</span>
}
</a>
}
</div>
}
<div class="container-divider">
<div class="line"><mat-divider></mat-divider></div>
<div class="text">{{ "login.or" | translate | uppercase }}</div>
<div class="line"><mat-divider></mat-divider></div>
</div>
</div>
</div>
}
<mat-form-field class="tb-appearance-transparent">
<mat-label translate>login.username</mat-label>
<input id="username-input" matInput type="email" autofocus formControlName="username"/>

36
ui-ngx/src/app/modules/login/pages/login/login.component.scss

@ -59,8 +59,11 @@
}
.text {
padding-right: 10px;
padding-left: 10px;
padding-right: 8px;
padding-left: 8px;
font-size: 16px;
line-height: 24px;
letter-spacing: 0.15px;
}
}
@ -72,14 +75,33 @@
a.login-with-button {
color: rgba(black, 0.87);
background-color: map-get($tb-dark-theme-background, raised-button);
}
.login-button-container {
a.login-with-button {
--mdc-outlined-button-container-shape: 8px;
max-width: 123px;
min-width: 60px;
flex-grow: 1;
flex-basis: 120px;
.tb-mat-20 {
margin: 0;
vertical-align: text-top;
}
}
&:hover {
border-bottom: 0;
&:has(> :nth-child(2):last-child) {
a.login-with-button {
max-width: 180px;
flex-basis: 180px;
}
}
.icon {
height: 20px;
width: 20px;
&:has(> :nth-child(3):last-child) {
a.login-with-button {
max-width: 180px;
}
}
}
}

20
ui-ngx/src/app/modules/login/pages/login/reset-password.component.html

@ -35,15 +35,28 @@
<span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.new-password</mat-label>
<input matInput type="password" autofocus formControlName="newPassword"/>
<input matInput
type="password"
autofocus
cdkOverlayOrigin
#passwordTrigger="cdkOverlayOrigin"
(focus)="passwordTooltip.onFocus()"
(blur)="passwordTooltip.onBlur()"
formControlName="newPassword"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="this.resetPassword.get('newPassword').errors">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block tb-appearance-transparent">
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" formControlName="newPassword2"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="resetPassword.get('newPassword2').hasError('passwordsNotMatch')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
<div class="flex flex-col items-center justify-start gap-4 gt-sm:flex-row gt-sm:items-start gt-sm:justify-center">
<button mat-raised-button color="accent" type="submit" [disabled]="(isLoading$ | async)">
@ -60,3 +73,8 @@
</mat-card-content>
</mat-card>
</div>
<tb-password-requirements-tooltip #passwordTooltip
[passwordControl]="resetPassword.get('newPassword')"
[passwordPolicy]="passwordPolicy"
[trigger]="passwordTrigger">
</tb-password-requirements-tooltip>

60
ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts

@ -14,61 +14,55 @@
/// limitations under the License.
///
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { AuthService } from '@core/auth/auth.service';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { UntypedFormBuilder } from '@angular/forms';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { UserPasswordPolicy } from '@shared/models/settings.models';
import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models';
@Component({
selector: 'tb-reset-password',
templateUrl: './reset-password.component.html',
styleUrls: ['./reset-password.component.scss']
})
export class ResetPasswordComponent extends PageComponent implements OnInit, OnDestroy {
export class ResetPasswordComponent extends PageComponent {
isExpiredPassword: boolean;
resetToken = '';
sub: Subscription;
resetPassword: FormGroup;
passwordPolicy: UserPasswordPolicy;
resetPassword = this.fb.group({
newPassword: [''],
newPassword2: ['']
});
private resetToken: string;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
constructor(private route: ActivatedRoute,
private router: Router,
private authService: AuthService,
private translate: TranslateService,
public fb: UntypedFormBuilder) {
super(store);
}
private fb: FormBuilder) {
super();
this.resetToken = this.route.snapshot.queryParams['resetToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
this.isExpiredPassword = this.route.snapshot.data['expiredPassword'] ?? false;
ngOnInit() {
this.isExpiredPassword = this.route.snapshot.data.expiredPassword;
this.sub = this.route
.queryParams
.subscribe(params => {
this.resetToken = params.resetToken || '';
});
this.buildResetPasswordForm();
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.sub.unsubscribe();
private buildResetPasswordForm() {
this.resetPassword = this.fb.group({
newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]],
newPassword2: ['']
}, {
validators: [
passwordsMatchValidator('newPassword', 'newPassword2'),
]
});
}
onResetPassword() {
if (this.resetPassword.get('newPassword').value !== this.resetPassword.get('newPassword2').value) {
this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'),
type: 'error' }));
if (this.resetPassword.invalid) {
this.resetPassword.markAllAsTouched();
} else {
this.authService.resetPassword(
this.resetToken,

35
ui-ngx/src/app/shared/components/password-requirements-tooltip.component.html

@ -0,0 +1,35 @@
<!--
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-template cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isTooltipOpen"
[cdkConnectedOverlayPositions]="overlayPositions">
<div class="password-checklist-card">
@for (rule of passwordErrorRules; track $index) {
@if (!rule.policyProp || passwordPolicy[rule.policyProp] > 0) {
<p class="mat-body flex text-sm">
<tb-icon class="tb-mat-20">
{{ checkForError(rule.key) ? 'mdi:close' : 'mdi:check' }}
</tb-icon>
{{ rule.translation | translate : passwordPolicy }}
</p>
}
}
<div class="tooltip-arrow"></div>
</div>
</ng-template>

48
ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss

@ -0,0 +1,48 @@
/**
* 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.
*/
.password-checklist-card {
background-color: #0000009E;
backdrop-filter: blur(8px);
color: white;
padding: 12px 16px;
border-radius: 8px;
position: relative;
min-width: 220px;
display: flex;
gap: 8px;
flex-direction: column;
& > tb-icon {
color: white;
}
& > p {
margin: 0;
}
& > .tooltip-arrow {
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #002b36;
}
}

53
ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts

@ -0,0 +1,53 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { CdkOverlayOrigin, ConnectionPositionPair } from '@angular/cdk/overlay';
import { passwordErrorRules } from '@shared/models/password.models';
import { AbstractControl } from '@angular/forms';
import { UserPasswordPolicy } from '@shared/models/settings.models';
import { POSITION_MAP } from '@shared/models/overlay.models';
@Component({
selector: 'tb-password-requirements-tooltip',
templateUrl: './password-requirements-tooltip.component.html',
styleUrl: './password-requirements-tooltip.component.scss',
encapsulation: ViewEncapsulation.None
})
export class PasswordRequirementsTooltipComponent {
@Input() passwordControl: AbstractControl;
@Input() passwordPolicy: UserPasswordPolicy;
@Input() trigger: CdkOverlayOrigin;
passwordErrorRules = passwordErrorRules;
isTooltipOpen = false;
overlayPositions: ConnectionPositionPair[] = [
{...POSITION_MAP.top, offsetY: -20}
];
checkForError(errorName: string): boolean {
return this.passwordControl?.hasError(errorName) ?? false;
}
onFocus(): void {
this.isTooltipOpen = true;
}
onBlur(): void {
this.isTooltipOpen = false;
}
}

2
ui-ngx/src/app/shared/models/api-key.models.ts

@ -19,7 +19,7 @@ import { HasTenantId } from '@shared/models/entity.models';
import { ApiKeyId } from '@shared/models/id/api-key-id';
import { UserId } from '@shared/models/id/user-id';
export const userInfoCommand = (key: string): string => `curl -X GET "${window.location.origin}/api/auth/user" -H "Content-Type: application/json" -H "X-Authorization: ApiKey ${key}"`
export const userInfoCommand = (baseUrl: string, apiKey: string): string => `curl -X GET "${baseUrl}/api/auth/user" -H "Content-Type: application/json" -H "X-Authorization: ApiKey ${apiKey}"`
export interface ApiKeyInfo extends BaseData<ApiKeyId>, HasTenantId {
enabled: boolean;

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

@ -233,6 +233,7 @@ export type AttributeOutputStrategy =
export interface AttributeImmediateOutputStrategy {
type: OutputStrategyType.IMMEDIATE;
updateAttributesOnlyOnValueChange: boolean;
sendAttributesUpdatedNotification: boolean;
saveAttribute: boolean;
sendWsUpdate: boolean;
processCfs: boolean;

106
ui-ngx/src/app/shared/models/password.models.ts

@ -0,0 +1,106 @@
///
/// 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 { UserPasswordPolicy } from '@shared/models/settings.models';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { isEqual } from '@core/utils';
export enum TooltipPasswordErrorMessageKey {
minLength = 'security.password-requirement.password-tooltip-min-length',
maxLength = 'security.password-requirement.password-tooltip-max-length',
notUpperCase = 'security.password-requirement.password-tooltip-uppercase',
notLowerCase = 'security.password-requirement.password-tooltip-lowercase',
notNumeric = 'security.password-requirement.password-tooltip-digit',
notSpecial = 'security.password-requirement.password-tooltip-special-characters',
hasWhitespaces = 'security.password-requirement.password-should-not-contain-spaces'
}
export const passwordErrorRules = [
{ key: 'minLength', policyProp: 'minimumLength', translation: TooltipPasswordErrorMessageKey.minLength },
{ key: 'notUpperCase', policyProp: 'minimumUppercaseLetters', translation: TooltipPasswordErrorMessageKey.notUpperCase },
{ key: 'notLowerCase', policyProp: 'minimumLowercaseLetters', translation: TooltipPasswordErrorMessageKey.notLowerCase },
{ key: 'notNumeric', policyProp: 'minimumDigits', translation: TooltipPasswordErrorMessageKey.notNumeric },
{ key: 'notSpecial', policyProp: 'minimumSpecialCharacters', translation: TooltipPasswordErrorMessageKey.notSpecial },
{ key: 'maxLength', policyProp: 'maximumLength', translation: TooltipPasswordErrorMessageKey.maxLength },
{ key: 'hasWhitespaces', policyProp: 'hasWhitespaces', translation: TooltipPasswordErrorMessageKey.hasWhitespaces },
];
export const passwordsMatchValidator = (firstControlName: string, secondControlName: string): ValidatorFn =>{
return (group: AbstractControl): ValidationErrors | null => {
const newPassControl = group.get(firstControlName);
const confirmControl = group.get(secondControlName);
if (!newPassControl || !confirmControl) {
return null;
}
const newPass = newPassControl.value ?? '';
const confirm = confirmControl.value ?? '';
if ((newPass || confirm) && confirm !== newPass) {
confirmControl.setErrors({ passwordsNotMatch: true });
return { passwordsNotMatch: true };
} else {
const currentErrors = confirmControl?.errors;
if (currentErrors?.['passwordsNotMatch']) {
const { passwordsNotMatch, ...rest } = currentErrors;
confirmControl?.setErrors(Object.keys(rest).length ? rest : null);
}
return null;
}
};
}
export const passwordStrengthValidator = (passwordPolicy: UserPasswordPolicy): ValidatorFn => {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value;
const errors: any = {};
if (passwordPolicy.minimumUppercaseLetters > 0 &&
!new RegExp(`(?:.*?[A-Z]){${passwordPolicy.minimumUppercaseLetters}}`).test(value)) {
errors.notUpperCase = true;
}
if (passwordPolicy.minimumLowercaseLetters > 0 &&
!new RegExp(`(?:.*?[a-z]){${passwordPolicy.minimumLowercaseLetters}}`).test(value)) {
errors.notLowerCase = true;
}
if (passwordPolicy.minimumDigits > 0
&& !new RegExp(`(?:.*?\\d){${passwordPolicy.minimumDigits}}`).test(value)) {
errors.notNumeric = true;
}
if (passwordPolicy.minimumSpecialCharacters > 0 &&
!new RegExp(`(?:.*?[\\W_]){${passwordPolicy.minimumSpecialCharacters}}`).test(value)) {
errors.notSpecial = true;
}
if (!passwordPolicy.allowWhitespaces && /\s/.test(value)) {
errors.hasWhitespaces = true;
}
if (passwordPolicy.minimumLength > 0 && value.length < passwordPolicy.minimumLength) {
errors.minLength = true;
}
if (!value.length || passwordPolicy.maximumLength > 0 && value.length > passwordPolicy.maximumLength) {
errors.maxLength = true;
}
return isEqual(errors, {}) ? null : errors;
};
}

1
ui-ngx/src/app/shared/models/public-api.ts

@ -71,3 +71,4 @@ export * from './query/query.models';
export * from './regex.constants';
export * from './trendz-settings.models';
export * from './ai-model.models';
export * from './password.models';

5
ui-ngx/src/app/shared/models/time/time.models.ts

@ -315,8 +315,9 @@ const getTimewindowType = (timewindow: Timewindow): TimewindowType => {
};
export const initModelFromDefaultTimewindow = (value: Timewindow, quickIntervalOnly: boolean,
historyOnly: boolean, timeService: TimeService, hasAggregation: boolean): Timewindow => {
const model = defaultTimewindow(timeService);
historyOnly: boolean, timeService: TimeService, hasAggregation: boolean,
isDashboard = false): Timewindow => {
const model = defaultTimewindow(timeService, isDashboard);
if (value) {
if (value.allowedAggTypes?.length) {
model.allowedAggTypes = value.allowedAggTypes;

11
ui-ngx/src/app/shared/shared.module.ts

@ -220,14 +220,19 @@ import { CountryData } from '@shared/models/country.models';
import { SvgXmlComponent } from '@shared/components/svg-xml.component';
import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component';
import { AggregationTypeSelectComponent } from '@shared/components/time/aggregation/aggregation-type-select.component';
import { AggregationOptionsConfigPanelComponent } from '@shared/components/time/aggregation/aggregation-options-config-panel.component';
import {
AggregationOptionsConfigPanelComponent
} from '@shared/components/time/aggregation/aggregation-options-config-panel.component';
import { IntervalOptionsConfigPanelComponent } from '@shared/components/time/interval-options-config-panel.component';
import { GroupingIntervalOptionsComponent } from '@shared/components/time/aggregation/grouping-interval-options.component';
import {
GroupingIntervalOptionsComponent
} from '@shared/components/time/aggregation/grouping-interval-options.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component';
import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component';
import { PasswordRequirementsTooltipComponent } from '@shared/components/password-requirements-tooltip.component';
import { StringPatternAutocompleteComponent } from '@shared/components/string-pattern-autocomplete.component';
import { TimeUnitInputComponent } from '@shared/components/time-unit-input.component';
import { DateExpirationPipe } from '@shared/pipe/date-expiration.pipe';
@ -448,6 +453,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
MqttVersionSelectComponent,
PasswordRequirementsTooltipComponent,
TimeUnitInputComponent,
StringPatternAutocompleteComponent
],
@ -715,6 +721,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
MqttVersionSelectComponent,
PasswordRequirementsTooltipComponent,
TimeUnitInputComponent,
StringPatternAutocompleteComponent
]

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

@ -772,6 +772,7 @@
"name-max-length": "Name should be less than 256",
"label-max-length": "Label should be less than 256",
"description": "Description",
"description-required": "Description is required.",
"type": "Type",
"type-required": "Type is required.",
"details": "Details",
@ -1011,6 +1012,7 @@
"generated-api-key-title": "API key generated. Let’s check connectivity!",
"generated-api-key-copy": "Make sure to copy and save your API key now as you will not be able to see it again.",
"generated-api-key-command": "Use the following instructions to check connectivity. As a result, you should receive the current user information:",
"generated-api-key-insecure-url": "Executing commands over an insecure HTTP connection will send your API key unencrypted, making it vulnerable to interception.",
"list": "{ count, plural, =1 {One API key} other {List of # API keys} }",
"manage": "Manage",
"manage-api-keys": "Manage API keys",
@ -1257,20 +1259,25 @@
"save-latest-values": "Save to latest values",
"send-web-sockets": "Send to WebSockets",
"save-calculated-fields": "Send to Calculated fields",
"update-attributes-only-on-value-change": "Save attributes only if the value changes",
"update-attribute-only-on-value-change": "Update attribute only on value change",
"send-attributes-updated-notification": "Send attributes updated notification",
"ttl": "Custom TTL",
"ttl-required": "TTL is required",
"ttl-min": "Only 0 minimum TTL is allowed",
"processing-parameters": "Processing parameters",
"hint": {
"strategy": "Controls whether the result is processed immediately or sent to a rule chain for additional processing",
"processing-options": "Processing options",
"update-attributes-only-on-value-change": "Updates the attributes on every incoming message disregarding if their value has changed. Increases API usage and reduces performance.",
"update-attributes-only-on-value-change-enabled": "Updates the attributes only if their value has changed. If the value is not changed, no update to the attribute timestamp nor attribute change notification will be sent.",
"update-attribute-only-on-value-change": "Updates attribute on every incoming message, regardless of whether the value has changed. This increases API usage and reduces performance.",
"update-attribute-only-on-value-change-enabled": "Updates attribute only when the value changes. If the value is unchanged, timestamps are not updated and notifications are not sent.",
"send-attributes-updated-notification": "Sends an Attributes Updated event to the default rule chain.",
"save-time-series": "Saves time series data to the ts_kv table in the database.",
"save-database": "Saves attribute data to the database.",
"save-latest-values": "Updates time series data in the ts_kv_latest table in the database if the new value is more recent.",
"send-web-sockets": "Notifies WebSocket subscriptions about updates to the attribute data.",
"save-calculated-fields": "Notifies calculated fields about updates to the attribute data.",
"send-web-sockets-attribute": "Notifies WebSocket subscriptions about updates to the attribute data.",
"send-web-sockets-time-series": "Notifies WebSocket subscriptions about updates to the time series data.",
"save-calculated-fields-attribute": "Notifies calculated fields about updates to the attribute data.",
"save-calculated-fields-time-series": "Notifies calculated fields about updates to the time series data.",
"ttl": "Defines the retention period for time series data. If disabled, the Tenant Profile TTL is used."
}
},
@ -4891,6 +4898,12 @@
"at-least": "At least:",
"character": "{ count, plural, =1 {1 character} other {# characters} }",
"digit": "{ count, plural, =1 {1 digit} other {# digits} }",
"password-tooltip-min-length": "At least {{minimumLength}} characters long",
"password-tooltip-max-length": "At most {{maximumLength}} characters long",
"password-tooltip-uppercase": "{{minimumUppercaseLetters}} uppercase character",
"password-tooltip-lowercase": "{{minimumLowercaseLetters}} lowercase character",
"password-tooltip-digit": "{{minimumDigits}} number",
"password-tooltip-special-characters": "{{minimumSpecialCharacters}} special character",
"incorrect-password-try-again": "Incorrect password. Try again",
"lowercase-letter": "{ count, plural, =1 {1 lowercase letter} other {# lowercase letters} }",
"new-passwords-not-match": "New password didn't match",

Loading…
Cancel
Save