Browse Source

Merge remote-tracking branch 'upstream/master' into propagation-cf/improvements

pull/14560/head
dshvaika 8 months ago
parent
commit
337d88f9fb
  1. 1
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  2. 7
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  3. 3
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java
  4. 24
      application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java
  5. 8
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java
  6. 36
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java
  7. 15
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java
  8. 3
      application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java
  9. 68
      application/src/main/resources/thingsboard.yml
  10. 62
      application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java
  11. 14
      common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java
  12. 29
      common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaAdmin.java
  13. 1
      common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java
  14. 25
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java
  15. 58
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java
  16. 13
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java
  17. 22
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java
  18. 2
      edqs/src/main/resources/edqs.yml
  19. 2
      msa/edqs/docker/Dockerfile
  20. 2
      msa/monitoring/docker/Dockerfile
  21. 1
      msa/pom.xml
  22. 2
      msa/tb-node/docker/Dockerfile
  23. 2
      msa/transport/coap/docker/Dockerfile
  24. 2
      msa/transport/http/docker/Dockerfile
  25. 2
      msa/transport/lwm2m/docker/Dockerfile
  26. 2
      msa/transport/mqtt/docker/Dockerfile
  27. 2
      msa/transport/snmp/docker/Dockerfile
  28. 2
      msa/vc-executor-docker/docker/Dockerfile
  29. 2
      msa/vc-executor/src/main/resources/tb-vc-executor.yml
  30. 8
      packaging/java/build.gradle
  31. 4
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  32. 16
      transport/coap/src/main/resources/tb-coap-transport.yml
  33. 2
      transport/http/src/main/resources/tb-http-transport.yml
  34. 18
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  35. 2
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  36. 2
      transport/snmp/src/main/resources/tb-snmp-transport.yml
  37. 5
      ui-ngx/src/app/core/http/asset-profile.service.ts
  38. 4
      ui-ngx/src/app/core/http/customer.service.ts
  39. 5
      ui-ngx/src/app/core/http/dashboard.service.ts
  40. 5
      ui-ngx/src/app/core/http/device-profile.service.ts
  41. 5
      ui-ngx/src/app/core/http/entity-view.service.ts
  42. 36
      ui-ngx/src/app/core/http/entity.service.ts
  43. 5
      ui-ngx/src/app/core/http/rule-chain.service.ts
  44. 4
      ui-ngx/src/app/core/http/tenant.service.ts
  45. 4
      ui-ngx/src/app/core/http/user.service.ts
  46. 5
      ui-ngx/src/app/core/http/widget.service.ts
  47. 8
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss
  48. 8
      ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss
  49. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss
  50. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  51. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
  52. 14
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html
  53. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts
  54. 11
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  55. 3
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html
  56. 3
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html
  57. 3
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
  58. 34
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts
  59. 177
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts
  60. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html
  61. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html
  62. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html
  63. 71
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.html
  64. 209
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts
  65. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.html
  66. 12
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.ts
  67. 48
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html
  68. 27
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts
  69. 117
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts
  70. 8
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html
  71. 88
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts
  72. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts
  73. 7
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  74. 4
      ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts
  75. 2
      ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts
  76. 2
      ui-ngx/src/app/modules/home/pages/asset/asset.component.ts
  77. 2
      ui-ngx/src/app/modules/home/pages/device/device.component.ts
  78. 2
      ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts
  79. 90
      ui-ngx/src/app/modules/login/pages/login/create-password.component.html
  80. 16
      ui-ngx/src/app/modules/login/pages/login/create-password.component.ts
  81. 25
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html
  82. 1
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss
  83. 7
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.html
  84. 1
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss
  85. 16
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts
  86. 114
      ui-ngx/src/app/modules/login/pages/login/login.component.html
  87. 78
      ui-ngx/src/app/modules/login/pages/login/login.component.scss
  88. 25
      ui-ngx/src/app/modules/login/pages/login/login.component.ts
  89. 5
      ui-ngx/src/app/modules/login/pages/login/password.component.scss
  90. 52
      ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html
  91. 29
      ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss
  92. 7
      ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts
  93. 94
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
  94. 34
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss
  95. 19
      ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
  96. 6
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html
  97. 1
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss
  98. 47
      ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts
  99. 85
      ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.ts
  100. 27
      ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay-container.ts

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

@ -132,6 +132,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
} else {
removeState(cfId);
}
msg.getCallback().onSuccess();
}
public void process(CalculatedFieldStatePartitionRestoreMsg msg) {

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

@ -167,10 +167,13 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
if (ctx != null) {
msg.setCtx(ctx);
log.debug("Pushing CF state restore msg to specific actor [{}]", msg.getId().entityId());
log.debug("[{}] Pushing CF state restore msg to specific actor [{}]", tenantId, msg.getId().entityId());
getOrCreateActor(msg.getId().entityId()).tellWithHighPriority(msg);
} else {
} else if (msg.getState() != null) {
log.debug("[{}] Received CF state restore msg for non-existing CF [{}]. Removing state", tenantId, cfId);
cfStateService.deleteState(msg.getId(), msg.getCallback());
} else {
msg.getCallback().onSuccess();
}
}

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

@ -19,6 +19,7 @@ import lombok.Data;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
@ -30,6 +31,7 @@ public class CalculatedFieldStateRestoreMsg implements ToCalculatedFieldSystemMs
private final CalculatedFieldEntityCtxId id;
private final CalculatedFieldState state;
private final TopicPartitionInfo partition;
private final TbCallback callback;
private CalculatedFieldCtx ctx;
@Override
@ -41,4 +43,5 @@ public class CalculatedFieldStateRestoreMsg implements ToCalculatedFieldSystemMs
public TenantId getTenantId() {
return id.tenantId();
}
}

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

@ -43,7 +43,6 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.TbActorMsg;
import org.thingsboard.server.common.msg.TbActorStopReason;
import org.thingsboard.server.common.msg.TbMsg;
@ -139,13 +138,22 @@ public class TenantActor extends RuleChainManagerActor {
@Override
protected boolean doProcess(TbActorMsg msg) {
if (cantFindTenant) {
log.info("[{}] Processing missing Tenant msg: {}", tenantId, msg);
if (msg.getMsgType().equals(MsgType.QUEUE_TO_RULE_ENGINE_MSG)) {
QueueToRuleEngineMsg queueMsg = (QueueToRuleEngineMsg) msg;
queueMsg.getMsg().getCallback().onSuccess();
} else if (msg.getMsgType().equals(MsgType.TRANSPORT_TO_DEVICE_ACTOR_MSG)) {
TransportToDeviceActorMsgWrapper transportMsg = (TransportToDeviceActorMsgWrapper) msg;
transportMsg.getCallback().onSuccess();
log.debug("[{}] Processing message for non-existing tenant: {}", tenantId, msg);
switch (msg.getMsgType()) {
case QUEUE_TO_RULE_ENGINE_MSG -> {
((QueueToRuleEngineMsg) msg).getMsg().getCallback().onSuccess();
}
case TRANSPORT_TO_DEVICE_ACTOR_MSG -> {
((TransportToDeviceActorMsgWrapper) msg).getCallback().onSuccess();
}
case CF_STATE_RESTORE_MSG -> {
((CalculatedFieldStateRestoreMsg) msg).getCallback().onSuccess();
}
default -> {
if (!log.isDebugEnabled()) {
log.info("[{}] Processing message for non-existing tenant: {}", tenantId, msg);
}
}
}
return true;
}

8
application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java

@ -68,7 +68,7 @@ public abstract class AbstractCalculatedFieldStateService implements CalculatedF
protected abstract void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback);
protected void processRestoredState(CalculatedFieldStateProto stateMsg, TopicPartitionInfo partition) {
protected void processRestoredState(CalculatedFieldStateProto stateMsg, TopicPartitionInfo partition, TbCallback callback) {
var id = fromProto(stateMsg.getId());
if (partition == null) {
try {
@ -79,12 +79,12 @@ public abstract class AbstractCalculatedFieldStateService implements CalculatedF
}
}
var state = fromProto(id, stateMsg);
processRestoredState(id, state, partition);
processRestoredState(id, state, partition, callback);
}
protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state, TopicPartitionInfo partition) {
protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state, TopicPartitionInfo partition, TbCallback callback) {
partition = partition.withTopic(DataConstants.CF_STATES_QUEUE_NAME);
actorSystemContext.tellWithHighPriority(new CalculatedFieldStateRestoreMsg(id, state, partition));
actorSystemContext.tellWithHighPriority(new CalculatedFieldStateRestoreMsg(id, state, partition, callback));
}
@Override

36
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java

@ -43,6 +43,8 @@ import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory;
import org.thingsboard.server.service.cf.AbstractCalculatedFieldStateService;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.bytesToString;
@ -61,6 +63,8 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta
@Value("${queue.calculated_fields.poll_interval:25}")
private long pollInterval;
@Value("${queue.calculated_fields.pack_processing_timeout:60000}")
private long packProcessingTimeout;
private TbKafkaProducerTemplate<TbProtoQueueMsg<CalculatedFieldStateProto>> stateProducer;
@ -74,21 +78,39 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta
.topic(partitionService.getTopic(queueKey))
.pollInterval(pollInterval)
.msgPackProcessor((msgs, consumer, consumerKey, config) -> {
CountDownLatch completionLatch = new CountDownLatch(msgs.size());
for (TbProtoQueueMsg<CalculatedFieldStateProto> msg : msgs) {
TbCallback callback = new TbCallback() {
@Override
public void onSuccess() {
int processedMsgCount = counter.incrementAndGet();
if (processedMsgCount % 10000 == 0) {
log.info("Processed {} CF state messages", processedMsgCount);
}
completionLatch.countDown();
}
@Override
public void onFailure(Throwable t) {
log.error("Failed to process CF state message: {}", msg, t);
completionLatch.countDown();
}
};
try {
if (msg.getValue() != null) {
processRestoredState(msg.getValue(), consumerKey.partition());
processRestoredState(msg.getValue(), consumerKey.partition(), callback);
} else {
processRestoredState(getStateId(msg.getHeaders()), null, consumerKey.partition());
processRestoredState(getStateId(msg.getHeaders()), null, consumerKey.partition(), callback);
}
} catch (Throwable t) {
log.error("Failed to process state message: {}", msg, t);
callback.onFailure(t);
}
}
int processedMsgCount = counter.incrementAndGet();
if (processedMsgCount % 10000 == 0) {
log.info("Processed {} calculated field state msgs", processedMsgCount);
}
boolean success = completionLatch.await(packProcessingTimeout, TimeUnit.MILLISECONDS);
if (!success) {
log.error("Timeout to process CF state messages pack of size {}", msgs.size());
}
})
.consumerCreator((queueConfig, tpi) -> queueFactory.createCalculatedFieldStateConsumer())

15
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java

@ -62,11 +62,22 @@ public class RocksDBCalculatedFieldStateService extends AbstractCalculatedFieldS
public void restore(QueueKey queueKey, Set<TopicPartitionInfo> partitions) {
if (stateService.getPartitions().isEmpty()) {
cfRocksDb.forEach((key, value) -> {
CalculatedFieldStateProto stateMsg;
try {
processRestoredState(CalculatedFieldStateProto.parseFrom(value), null);
stateMsg = CalculatedFieldStateProto.parseFrom(value);
} catch (Exception e) {
log.error("[{}] Failed to process restored state", key, e);
log.error("Failed to parse CalculatedFieldStateProto for key {}", key, e);
return;
}
processRestoredState(stateMsg, null, new TbCallback() {
@Override
public void onSuccess() {}
@Override
public void onFailure(Throwable t) {
log.error("Failed to process CF state message: {}", stateMsg, t);
}
});
});
}
super.restore(queueKey, partitions);

3
application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java

@ -258,8 +258,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService<Device> {
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration();
transportConfiguration.setBootstrap(Collections.emptyList());
transportConfiguration.setClientLwM2mSettings(new OtherConfiguration(false,1, 1, 1, PowerMode.DRX, null, null, null, null, null, V1_0.toString()));
transportConfiguration.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap(), false, SINGLE));
transportConfiguration.setClientLwM2mSettings(new OtherConfiguration());
DeviceProfileData deviceProfileData = new DeviceProfileData();
DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration();

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

@ -516,14 +516,14 @@ actors:
response_timeout_ms: "${ACTORS_RPC_RESPONSE_TIMEOUT_MS:30000}"
# Close transport session if RPC delivery timed out. If enabled, RPC will be reverted to the queued state.
# Note:
# - For MQTT transport:
# - QoS level 0: This feature does not apply, as no acknowledgment is expected, and therefore no timeout is triggered.
# - QoS level 1: This feature applies, as an acknowledgment is expected.
# - QoS level 2: Unsupported.
# - For CoAP transport:
# - Confirmable requests: This feature applies, as delivery confirmation is expected.
# - Non-confirmable requests: This feature does not apply, as no delivery acknowledgment is expected.
# - For HTTP and SNPM transports: RPC is considered delivered immediately, and there is no logic to await acknowledgment.
# <ul><li> For MQTT transport:
# <ul><li> QoS level 0: This feature does not apply, as no acknowledgment is expected, and therefore no timeout is triggered.</li>
# <li> QoS level 1: This feature applies, as an acknowledgment is expected.</li>
# <li> QoS level 2: Unsupported.</li></ul></li>
# <li> For CoAP transport:
# <ul><li> Confirmable requests: This feature applies, as delivery confirmation is expected.</li>
# <li> Non-confirmable requests: This feature does not apply, as no delivery acknowledgment is expected.</li></ul></li>
# <li> For HTTP and SNPM transports: RPC is considered delivered immediately, and there is no logic to await acknowledgment.</li></ul>
close_session_on_rpc_delivery_timeout: "${ACTORS_RPC_CLOSE_SESSION_ON_RPC_DELIVERY_TIMEOUT:false}"
statistics:
# Enable/disable actor statistics
@ -733,13 +733,13 @@ redis:
# if set false will be used pool config build from values of the pool config section
useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}"
sentinel:
# name of the master node
# Name of the master node
master: "${REDIS_MASTER:}"
# comma-separated list of "host:port" pairs of sentinels
# Comma-separated list of "host:port" pairs of sentinels
sentinels: "${REDIS_SENTINELS:}"
# password to authenticate with sentinel
# Password to authenticate with sentinel
password: "${REDIS_SENTINEL_PASSWORD:}"
# if set false will be used pool config build from values of the pool config section
# If set false will be used pool config build from values of the pool config section
useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}"
# db index
db: "${REDIS_DB:0}"
@ -1039,10 +1039,10 @@ transport:
activity:
# This property specifies the strategy for reporting activity events within each reporting period.
# The accepted values are 'FIRST', 'LAST', 'FIRST_AND_LAST' and 'ALL'.
# - 'FIRST': Only the first activity event in each reporting period is reported.
# - 'LAST': Only the last activity event in the reporting period is reported.
# - 'FIRST_AND_LAST': Both the first and last activity events in the reporting period are reported.
# - 'ALL': All activity events in the reporting period are reported.
# <ul><li> 'FIRST': Only the first activity event in each reporting period is reported.</li>
# <li> 'LAST': Only the last activity event in the reporting period is reported.</li>
# <li> 'FIRST_AND_LAST': Both the first and last activity events in the reporting period are reported.</li>
# <li> 'ALL': All activity events in the reporting period are reported.</li></ul>
reporting_strategy: "${TB_TRANSPORT_ACTIVITY_REPORTING_STRATEGY:LAST}"
json:
# Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON
@ -1160,15 +1160,15 @@ transport:
dtls:
# RFC7925_RETRANSMISSION_TIMEOUT_IN_MILLISECONDS = 9000
retransmission_timeout: "${LWM2M_DTLS_RETRANSMISSION_TIMEOUT_MS:9000}"
# CoAP DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2
# Default: off
# LWM2M DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2
# Default: off. <br>
# Control usage of DTLS connection ID length (CID).
# - 'off' to deactivate it.
# - 'on' to activate Connection ID support (same as CID 0 or more 0).
# - A positive value defines generated CID size in bytes.
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
# <ul> <li> 'off' to deactivate it. </li>
# <li> 'on' to activate Connection ID support (same as CID 0 or more 0). </li>
# <li> A positive value defines generated CID size in bytes.</li>
# <li> A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). </li>
# <li> A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used </li>
# <li> A value that are > 4: MultiNodeConnectionIdGenerator is used </li> </ul>
connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}"
server:
# LwM2M Server ID
@ -1351,14 +1351,14 @@ coap:
# CoAP DTLS bind port
bind_port: "${COAP_DTLS_BIND_PORT:5684}"
# CoAP DTLS connection ID length. RFC 9146, Connection Identifier for DTLS 1.2
# Default: off
# Default: off. <br>
# Control usage of DTLS connection ID length (CID).
# - 'off' to deactivate it.
# - 'on' to activate Connection ID support (same as CID 0 or more 0).
# - A positive value defines generated CID size in bytes.
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
# <ul> <li> 'off' to deactivate it. </li>
# <li> 'on' to activate Connection ID support (same as CID 0 or more 0). </li>
# <li> A positive value defines generated CID size in bytes.</li>
# <li> A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).</li>
# <li> A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used</li>
# <li> A value that are > 4: MultiNodeConnectionIdGenerator is used</li> </ul>
connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}"
# Specify the MTU (Maximum Transmission Unit).
# Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU.
@ -1375,13 +1375,13 @@ coap:
# In order to negotiate smaller maximum fragment lengths,
# clients MAY include an extension of type "max_fragment_length" in the (extended) client hello.
# The "extension_data" field of this extension SHALL contain:
# enum {
# <pre> enum {
# 2^9(1) == 512,
# 2^10(2) == 1024,
# 2^11(3) == 2048,
# 2^12(4) == 4096,
# (255)
# } MaxFragmentLength;
# } MaxFragmentLength; </pre>
# TLS already requires clients and servers to support fragmentation of handshake messages.
max_fragment_length: "${COAP_DTLS_MAX_FRAGMENT_LENGTH:1024}"
# Server DTLS credentials
@ -1745,6 +1745,8 @@ queue:
print-interval-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_MIN_PRINT_INTERVAL_MS:60000}"
# Time to wait for the stats-loading requests to Kafka to finish
kafka-response-timeout-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_RESPONSE_TIMEOUT_MS:1000}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
transport_api:

62
application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java

@ -15,18 +15,24 @@
*/
package org.thingsboard.server.service.security.auth.pat;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
@ -101,6 +107,62 @@ public class ApiKeyAuthenticationProviderTest extends AbstractControllerTest {
doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized());
}
@Test
public void testUnauthorizedWhenUserCredentialsDisabled() throws Exception {
User newUser = new User();
newUser.setAuthority(Authority.TENANT_ADMIN);
newUser.setTenantId(tenantId);
newUser.setEmail("testUser" + RandomStringUtils.secure().nextAlphanumeric(10) + "@thingsboard.org");
newUser.setFirstName("Test");
newUser.setLastName("User");
User savedUser = createUser(newUser, "testPassword1");
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription("Test API key for user credentials test");
apiKeyInfo.setEnabled(true);
apiKeyInfo.setUserId(savedUser.getId());
ApiKey testApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
setApiKey(testApiKey.getValue());
doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isOk());
doPost("/api/user/" + savedUser.getId().getId() + "/userCredentialsEnabled?userCredentialsEnabled=false").andExpect(status().isOk());
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isUnauthorized()));
resetApiKey();
doDelete("/api/apiKey/" + testApiKey.getId()).andExpect(status().isOk());
loginSysAdmin();
doDelete("/api/user/" + savedUser.getId().getId()).andExpect(status().isOk());
}
@Test
public void testUnauthorizedWhenUserDeleted() throws Exception {
User newUser = new User();
newUser.setAuthority(Authority.TENANT_ADMIN);
newUser.setTenantId(tenantId);
newUser.setEmail("testUser" + RandomStringUtils.secure().nextAlphanumeric(10) + "@thingsboard.org");
newUser.setFirstName("Test");
newUser.setLastName("User");
User savedUser = createUser(newUser, "testPassword1");
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription("Test API key for user deletion test");
apiKeyInfo.setEnabled(true);
apiKeyInfo.setUserId(savedUser.getId());
ApiKey testApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
setApiKey(testApiKey.getValue());
doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isOk());
loginSysAdmin();
doDelete("/api/user/" + savedUser.getId().getId()).andExpect(status().isOk());
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isUnauthorized()));
}
private ApiKeyInfo constructApiKeyInfo() {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription("New API key description");

14
common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java

@ -17,10 +17,15 @@ package org.thingsboard.server.common.data.device.profile;
import lombok.Data;
import org.thingsboard.server.common.data.DeviceTransportType;
import org.thingsboard.server.common.data.device.data.PowerMode;
import org.thingsboard.server.common.data.device.profile.lwm2m.OtherConfiguration;
import org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryMappingConfiguration;
import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MBootstrapServerCredential;
import static org.eclipse.leshan.core.LwM2m.Version.V1_0;
import static org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryObserveStrategy.SINGLE;
import java.util.Collections;
import java.util.List;
@Data
@ -33,9 +38,18 @@ public class Lwm2mDeviceProfileTransportConfiguration implements DeviceProfileTr
private List<LwM2MBootstrapServerCredential> bootstrap;
private OtherConfiguration clientLwM2mSettings;
public Lwm2mDeviceProfileTransportConfiguration() {
updateDefault();
}
@Override
public DeviceTransportType getType() {
return DeviceTransportType.LWM2M;
}
private void updateDefault(){
this.setBootstrap(Collections.emptyList());
this.setClientLwM2mSettings(new OtherConfiguration(false,1, 1, 1, PowerMode.DRX, null, null, null, null, null, V1_0.toString()));
this.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap(), false, SINGLE));
}
}

29
common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaAdmin.java

@ -58,15 +58,12 @@ public class KafkaAdmin {
private final TbKafkaSettings settings;
@Value("${queue.kafka.request.timeout.ms:30000}")
private int requestTimeoutMs;
@Value("${queue.kafka.topics_cache_ttl_ms:300000}") // 5 minutes by default
private int topicsCacheTtlMs;
private final LazyInitializer<AdminClient> adminClient;
private final CachedValue<Set<String>> topics;
public KafkaAdmin(@Lazy TbKafkaSettings settings) {
public KafkaAdmin(@Lazy TbKafkaSettings settings,
@Value("${queue.kafka.topics_cache_ttl_ms:300000}")
int topicsCacheTtlMs) {
this.settings = settings;
this.adminClient = LazyInitializer.<AdminClient>builder()
.setInitializer(() -> AdminClient.create(settings.toAdminProps()))
@ -91,7 +88,7 @@ public class KafkaAdmin {
NewTopic newTopic = new NewTopic(topic, partitions, settings.getReplicationFactor()).configs(properties);
try {
getClient().createTopics(List.of(newTopic)).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
getClient().createTopics(List.of(newTopic)).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
topics.add(topic);
} catch (ExecutionException ee) {
log.trace("Failed to create topic {} with properties {}", topic, properties, ee);
@ -110,7 +107,7 @@ public class KafkaAdmin {
public void deleteTopic(String topic) {
log.debug("Deleting topic {}", topic);
try {
getClient().deleteTopics(List.of(topic)).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
getClient().deleteTopics(List.of(topic)).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.error("Failed to delete kafka topic [{}].", topic, e);
}
@ -122,7 +119,7 @@ public class KafkaAdmin {
public Set<String> listTopics() {
try {
Set<String> topics = getClient().listTopics().names().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
Set<String> topics = getClient().listTopics().names().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
log.trace("Listed topics: {}", topics);
return topics;
} catch (Exception e) {
@ -150,7 +147,7 @@ public class KafkaAdmin {
.collect(Collectors.toMap(tp -> tp, tp -> OffsetSpec.latest()));
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> endOffsets =
getClient().listOffsets(latestOffsetsSpec).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
getClient().listOffsets(latestOffsetsSpec).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
return committedOffsets.entrySet().stream()
.mapToLong(entry -> {
@ -169,7 +166,7 @@ public class KafkaAdmin {
@SneakyThrows
public Map<TopicPartition, OffsetAndMetadata> getConsumerGroupOffsets(String groupId) {
return getClient().listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
return getClient().listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
}
/**
@ -212,7 +209,7 @@ public class KafkaAdmin {
} else {
log.info("[{}] SHOULD alter topic offset [{}] less than old node group offset [{}]", tp, existingOffset.offset(), om.offset());
}
getClient().alterConsumerGroupOffsets(newGroupId, Map.of(tp, om)).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
getClient().alterConsumerGroupOffsets(newGroupId, Map.of(tp, om)).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
log.info("[{}] altered new consumer groupId {}", tp, newGroupId);
break;
}
@ -229,7 +226,7 @@ public class KafkaAdmin {
return true;
}
List<TopicPartition> allPartitions = getClient().describeTopics(existingTopics).allTopicNames().get(requestTimeoutMs, TimeUnit.MILLISECONDS)
List<TopicPartition> allPartitions = getClient().describeTopics(existingTopics).allTopicNames().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS)
.entrySet().stream()
.flatMap(entry -> {
String topic = entry.getKey();
@ -239,9 +236,9 @@ public class KafkaAdmin {
.toList();
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> beginningOffsets = getClient().listOffsets(allPartitions.stream()
.collect(Collectors.toMap(partition -> partition, partition -> OffsetSpec.earliest()))).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
.collect(Collectors.toMap(partition -> partition, partition -> OffsetSpec.earliest()))).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> endOffsets = getClient().listOffsets(allPartitions.stream()
.collect(Collectors.toMap(partition -> partition, partition -> OffsetSpec.latest()))).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
.collect(Collectors.toMap(partition -> partition, partition -> OffsetSpec.latest()))).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
for (TopicPartition partition : allPartitions) {
long beginningOffset = beginningOffsets.get(partition).offset();
@ -261,7 +258,7 @@ public class KafkaAdmin {
public void deleteConsumerGroup(String consumerGroupId) {
try {
getClient().deleteConsumerGroups(List.of(consumerGroupId)).all().get(requestTimeoutMs, TimeUnit.MILLISECONDS);
getClient().deleteConsumerGroups(List.of(consumerGroupId)).all().get(settings.getRequestTimeoutMs(), TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.warn("Failed to delete consumer group {}", consumerGroupId, e);
}

1
common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java

@ -112,6 +112,7 @@ public class TbKafkaSettings {
@Value("${queue.kafka.fetch_max_bytes:134217728}")
private int fetchMaxBytes;
@Getter
@Value("${queue.kafka.request.timeout.ms:30000}")
private int requestTimeoutMs;

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

@ -360,20 +360,25 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
@Override
public Lwm2mDeviceProfileTransportConfiguration getProfile(Registration registration) {
UUID profileId = getClientByEndpoint(registration.getEndpoint()).getProfileId();
return doGetAndCache(profileId);
return profileId != null ? doGetAndCache(profileId) : null;
}
private Lwm2mDeviceProfileTransportConfiguration doGetAndCache(UUID profileId) {
Lwm2mDeviceProfileTransportConfiguration result = profiles.get(profileId);
if (result == null) {
log.debug("Fetching profile [{}]", profileId);
DeviceProfile deviceProfile = deviceProfileCache.get(new DeviceProfileId(profileId));
if (deviceProfile != null) {
result = profileUpdate(deviceProfile);
} else {
log.warn("Device profile was not found! Most probably device profile [{}] has been removed from the database.", profileId);
Lwm2mDeviceProfileTransportConfiguration result;
if (profileId != null) {
result = profiles.get(profileId);
if (result == null) {
log.debug("Fetching profile [{}]", profileId);
DeviceProfile deviceProfile = deviceProfileCache.get(new DeviceProfileId(profileId));
if (deviceProfile != null) {
result = profileUpdate(deviceProfile);
} else {
log.warn("Device profile was not found! Most probably device profile [{}] has been removed from the database.", profileId);
}
}
} else {
log.warn("Device profile not found! The device profile ID is null. Return Lwm2mDeviceProfileTransportConfiguration with default.");
result = new Lwm2mDeviceProfileTransportConfiguration();
}
return result;
}

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

@ -201,35 +201,39 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl
}
var clientSettings = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings();
initFwStrategy(client, clientSettings);
initSwStrategy(client, clientSettings);
if (!attributesToFetch.isEmpty()) {
var future = attributesService.getSharedAttributes(client, attributesToFetch);
DonAsynchron.withCallback(future, attrs -> {
if (fwInfo.isSupported()) {
Optional<String> newFwTitle = getAttributeValue(attrs, FIRMWARE_TITLE);
Optional<String> newFwVersion = getAttributeValue(attrs, FIRMWARE_VERSION);
Optional<String> newFwTag = getAttributeValue(attrs, FIRMWARE_TAG);
Optional<String> newFwUrl = getAttributeValue(attrs, FIRMWARE_URL);
if (newFwTitle.isPresent() && newFwVersion.isPresent() && !isOtaDownloading(client) && !UPDATING.equals(fwInfo.status)) {
onTargetFirmwareUpdate(client, newFwTitle.get(), newFwVersion.get(), newFwUrl, newFwTag);
if (clientSettings != null) {
initFwStrategy(client, clientSettings);
initSwStrategy(client, clientSettings);
if (!attributesToFetch.isEmpty()) {
var future = attributesService.getSharedAttributes(client, attributesToFetch);
DonAsynchron.withCallback(future, attrs -> {
if (fwInfo.isSupported()) {
Optional<String> newFwTitle = getAttributeValue(attrs, FIRMWARE_TITLE);
Optional<String> newFwVersion = getAttributeValue(attrs, FIRMWARE_VERSION);
Optional<String> newFwTag = getAttributeValue(attrs, FIRMWARE_TAG);
Optional<String> newFwUrl = getAttributeValue(attrs, FIRMWARE_URL);
if (newFwTitle.isPresent() && newFwVersion.isPresent() && !isOtaDownloading(client) && !UPDATING.equals(fwInfo.status)) {
onTargetFirmwareUpdate(client, newFwTitle.get(), newFwVersion.get(), newFwUrl, newFwTag);
}
}
}
if (swInfo.isSupported()) {
Optional<String> newSwTitle = getAttributeValue(attrs, SOFTWARE_TITLE);
Optional<String> newSwVersion = getAttributeValue(attrs, SOFTWARE_VERSION);
Optional<String> newSwTag = getAttributeValue(attrs, SOFTWARE_TAG);
Optional<String> newSwUrl = getAttributeValue(attrs, SOFTWARE_URL);
if (newSwTitle.isPresent() && newSwVersion.isPresent()) {
onTargetSoftwareUpdate(client, newSwTitle.get(), newSwVersion.get(), newSwUrl, newSwTag);
if (swInfo.isSupported()) {
Optional<String> newSwTitle = getAttributeValue(attrs, SOFTWARE_TITLE);
Optional<String> newSwVersion = getAttributeValue(attrs, SOFTWARE_VERSION);
Optional<String> newSwTag = getAttributeValue(attrs, SOFTWARE_TAG);
Optional<String> newSwUrl = getAttributeValue(attrs, SOFTWARE_URL);
if (newSwTitle.isPresent() && newSwVersion.isPresent()) {
onTargetSoftwareUpdate(client, newSwTitle.get(), newSwVersion.get(), newSwUrl, newSwTag);
}
}
}
}, throwable -> {
if (fwInfo.isSupported()) {
update(fwInfo);
}
}, executor);
}, throwable -> {
if (fwInfo.isSupported()) {
update(fwInfo);
}
}, executor);
}
}
}

13
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java

@ -750,11 +750,14 @@ public class TbLwM2mRedisRegistrationStore implements RegistrationStore, Startab
System.currentTimeMillis(), 0, cleanLimit);
for (byte[] endpoint : endpointsExpired) {
Registration r = deserializeReg(connection.get(toEndpointKey(endpoint)));
if (!r.isAlive(gracePeriod)) {
Deregistration dereg = removeRegistration(connection, r.getId(), true);
if (dereg != null)
expirationListener.registrationExpired(dereg.getRegistration(), dereg.getObservations());
byte[] data = connection.get(toEndpointKey(endpoint));
if (data != null && data.length > 0) {
Registration r = deserializeReg(data);
if (!r.isAlive(gracePeriod)) {
Deregistration dereg = removeRegistration(connection, r.getId(), true);
if (dereg != null)
expirationListener.registrationExpired(dereg.getRegistration(), dereg.getObservations());
}
}
}
} catch (Exception e) {

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

@ -486,13 +486,18 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
*/
private void initClientTelemetry(LwM2mClient lwM2MClient) {
Lwm2mDeviceProfileTransportConfiguration profile = clientContext.getProfile(lwM2MClient.getRegistration());
Set<String> supportedObjects = clientContext.getSupportedIdVerInClient(lwM2MClient);
if (supportedObjects != null && !supportedObjects.isEmpty()) {
this.sendInitObserveRequests(lwM2MClient, profile, supportedObjects);
this.sendReadRequests(lwM2MClient, profile, supportedObjects);
this.sendWriteAttributeRequests(lwM2MClient, profile, supportedObjects);
if (profile != null) {
Set<String> supportedObjects = clientContext.getSupportedIdVerInClient(lwM2MClient);
if (supportedObjects != null && !supportedObjects.isEmpty()) {
this.sendInitObserveRequests(lwM2MClient, profile, supportedObjects);
this.sendReadRequests(lwM2MClient, profile, supportedObjects);
this.sendWriteAttributeRequests(lwM2MClient, profile, supportedObjects);
// Removed. Used only for debug.
// this.sendDiscoverRequests(lwM2MClient, profile, supportedObjects);
}
} else {
log.warn("[{}] Failed to process initClientTelemetry! Profile is null. Update procedure may not have completed after reboot yet", lwM2MClient.getEndpoint());
logService.log(lwM2MClient, "Failed to process initClientTelemetry. Profile is null. Update procedure may not have completed after reboot yet");
}
}
@ -1028,7 +1033,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
});
}
private void updateValueOta(List<LwM2mClient> clients, Lwm2mDeviceProfileTransportConfiguration oldProfile, Lwm2mDeviceProfileTransportConfiguration newProfile) {
private void updateValueOta(List<LwM2mClient> clients, Lwm2mDeviceProfileTransportConfiguration newProfile, Lwm2mDeviceProfileTransportConfiguration oldProfile) {
OtherConfiguration newLwM2mSettings = newProfile.getClientLwM2mSettings();
OtherConfiguration oldLwM2mSettings = oldProfile.getClientLwM2mSettings();
if (!newLwM2mSettings.getFwUpdateStrategy().equals(oldLwM2mSettings.getFwUpdateStrategy())
@ -1110,6 +1115,9 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
v -> attributesService.onAttributesUpdate(lwM2MClient, v, logFailedUpdateOfNonChangedValue),
t -> log.error("[{}] Failed to get attributes", lwM2MClient.getEndpoint(), t),
executor);
} else {
log.warn("[{}] Failed to process initAttributes! Profile is null. Update procedure may not have completed after reboot yet", lwM2MClient.getEndpoint());
logService.log(lwM2MClient, "Failed to process initAttributes. Profile is null. Update procedure may not have completed after reboot yet");
}
}
@ -1119,7 +1127,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl
private Map<String, String> getNamesFromProfileForSharedAttributes(LwM2mClient lwM2MClient) {
Lwm2mDeviceProfileTransportConfiguration profile = clientContext.getProfile(lwM2MClient.getRegistration());
return profile.getObserveAttr().getKeyName();
return profile != null ? profile.getObserveAttr().getKeyName() : Collections.emptyMap();
}
public LwM2MTransportServerConfig getConfig() {

2
edqs/src/main/resources/edqs.yml

@ -177,6 +177,8 @@ queue:
print-interval-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_MIN_PRINT_INTERVAL_MS:60000}"
# Time to wait for the stats-loading requests to Kafka to finish
kafka-response-timeout-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_RESPONSE_TIMEOUT_MS:1000}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256

2
msa/edqs/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-edqs.sh ${pkg.name}.deb /tmp/

2
msa/monitoring/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-monitoring.sh ${pkg.name}.deb /tmp/

1
msa/pom.xml

@ -32,6 +32,7 @@
<properties>
<main.dir>${basedir}/..</main.dir>
<docker.repo>thingsboard</docker.repo>
<docker.base.image>thingsboard/openjdk17:bookworm-slim</docker.base.image>
<dockerfile.skip>true</dockerfile.skip>
<blackBoxTests.skip>true</blackBoxTests.skip>
<dockerfile-maven.version>1.4.13</dockerfile-maven.version>

2
msa/tb-node/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY logback.xml start-tb-node.sh ${pkg.name}.deb /tmp/

2
msa/transport/coap/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-coap-transport.sh ${pkg.name}.deb /tmp/

2
msa/transport/http/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-http-transport.sh ${pkg.name}.deb /tmp/

2
msa/transport/lwm2m/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-lwm2m-transport.sh ${pkg.name}.deb /tmp/

2
msa/transport/mqtt/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-mqtt-transport.sh ${pkg.name}.deb /tmp/

2
msa/transport/snmp/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
COPY start-tb-snmp-transport.sh ${pkg.name}.deb /tmp/

2
msa/vc-executor-docker/docker/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License.
#
FROM thingsboard/openjdk17:bookworm-slim
FROM ${docker.base.image}
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \

2
msa/vc-executor/src/main/resources/tb-vc-executor.yml

@ -151,6 +151,8 @@ queue:
print-interval-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_MIN_PRINT_INTERVAL_MS:60000}"
# Time to wait for the stats-loading requests to Kafka to finis
kafka-response-timeout-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_RESPONSE_TIMEOUT_MS:1000}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
core:

8
packaging/java/build.gradle

@ -92,7 +92,11 @@ buildRpm {
archiveVersion = projectVersion.replace('-', '')
archiveFileName = "${pkgName}.rpm"
requires("(java-17 or java-17-headless or jre-17 or jre-17-headless)") // .or() notation does work in RPM plugin
// Support Java 17 (existing), plus Java 21 and Java 25 for RPM-based distros
// Keep using RPM boolean expression syntax since .or() chaining is for DEB only
requires("(java-17 or java-17-headless or jre-17 or jre-17-headless or " +
"java-21 or java-21-headless or jre-21 or jre-21-headless or " +
"java-25 or java-25-headless or jre-25 or jre-25-headless)")
from("${buildDir}/conf") {
include "${pkgName}.conf"
@ -132,6 +136,8 @@ buildDeb {
archiveFileName = "${pkgName}.deb"
requires("openjdk-17-jre").or("java17-runtime").or("oracle-java17-installer").or("openjdk-17-jre-headless")
.or("openjdk-21-jre").or("java21-runtime").or("oracle-java21-installer").or("openjdk-21-jre-headless")
.or("openjdk-25-jre").or("java25-runtime").or("oracle-java25-installer").or("openjdk-25-jre-headless")
from("${buildDir}/conf") {
include "${pkgName}.conf"

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

@ -4499,7 +4499,7 @@ public class RestClient implements Closeable {
public Optional<AiModel> getAiModel(AiModelId aiModelId) {
try {
ResponseEntity<AiModel> response = restTemplate.getForEntity(
baseURL + "/api/aiModel/{aiModelId}", AiModel.class, aiModelId.getId());
baseURL + "/api/ai/model/{aiModelId}", AiModel.class, aiModelId.getId());
return Optional.ofNullable(response.getBody());
} catch (HttpClientErrorException exception) {
if (exception.getStatusCode() == HttpStatus.NOT_FOUND) {
@ -4511,7 +4511,7 @@ public class RestClient implements Closeable {
}
public void deleteAiModel(AiModelId aiModelId) {
restTemplate.delete(baseURL + "/api/aiModel/{aiModelId}", aiModelId.getId());
restTemplate.delete(baseURL + "/api/ai/model/{aiModelId}", aiModelId.getId());
}

16
transport/coap/src/main/resources/tb-coap-transport.yml

@ -185,14 +185,14 @@ coap:
# CoAP DTLS bind port
bind_port: "${COAP_DTLS_BIND_PORT:5684}"
# CoAP DTLS connection ID length. RFC 9146, Connection Identifier for DTLS 1.2
# Default: off
# Default: off. <br>
# Control usage of DTLS connection ID length (CID).
# - 'off' to deactivate it.
# - 'on' to activate Connection ID support (same as CID 0 or more 0).
# - A positive value defines generated CID size in bytes.
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
# <ul> <li> 'off' to deactivate it. </li>
# <li> 'on' to activate Connection ID support (same as CID 0 or more 0). </li>
# <li> A positive value defines generated CID size in bytes.</li>
# <li> A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).</li>
# <li> A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used</li>
# <li> A value that are > 4: MultiNodeConnectionIdGenerator is used</li> </ul>
connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}"
# Specify the MTU (Maximum Transmission Unit).
# Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU.
@ -332,6 +332,8 @@ queue:
notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}"
# Kafka properties for Housekeeper tasks topic
housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
transport_api:

2
transport/http/src/main/resources/tb-http-transport.yml

@ -281,6 +281,8 @@ queue:
notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}"
# Kafka properties for Housekeeper tasks topic
housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
transport_api:

18
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -164,15 +164,15 @@ transport:
dtls:
# RFC7925_RETRANSMISSION_TIMEOUT_IN_MILLISECONDS = 9000
retransmission_timeout: "${LWM2M_DTLS_RETRANSMISSION_TIMEOUT_MS:9000}"
# CoAP DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2
# Default: off
# LWM2M DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2
# Default: off. <br>
# Control usage of DTLS connection ID length (CID).
# - 'off' to deactivate it.
# - 'on' to activate Connection ID support (same as CID 0 or more 0).
# - A positive value defines generated CID size in bytes.
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
# <ul> <li> 'off' to deactivate it. </li>
# <li> 'on' to activate Connection ID support (same as CID 0 or more 0). </li>
# <li> A positive value defines generated CID size in bytes.</li>
# <li> A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). </li>
# <li> A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used </li>
# <li> A value that are > 4: MultiNodeConnectionIdGenerator is used </li> </ul>
connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}"
server:
# LwM2M Server ID
@ -382,6 +382,8 @@ queue:
notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}"
# Kafka properties for Housekeeper tasks topic
housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
transport_api:

2
transport/mqtt/src/main/resources/tb-mqtt-transport.yml

@ -315,6 +315,8 @@ queue:
notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}"
# Kafka properties for Housekeeper tasks topic
housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
transport_api:

2
transport/snmp/src/main/resources/tb-snmp-transport.yml

@ -270,6 +270,8 @@ queue:
print-interval-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_MIN_PRINT_INTERVAL_MS:60000}"
# Time to wait for the stats-loading requests to Kafka to finis
kafka-response-timeout-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_RESPONSE_TIMEOUT_MS:1000}"
# Topics cache TTL in milliseconds. 5 minutes by default
topics_cache_ttl_ms: "${TB_QUEUE_KAFKA_TOPICS_CACHE_TTL_MS:300000}"
partitions:
hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256
transport_api:

5
ui-ngx/src/app/core/http/asset-profile.service.ts

@ -38,6 +38,11 @@ export class AssetProfileService {
return this.http.get<PageData<AssetProfile>>(`/api/assetProfiles${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getAssetProfilesByIds(assetProfileIds: Array<string>, config?: RequestConfig): Observable<Array<AssetProfileInfo>> {
return this.http.get<Array<AssetProfileInfo>>(`/api/assetProfileInfos?assetProfileIds=${assetProfileIds.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
public getAssetProfile(assetProfileId: string, config?: RequestConfig): Observable<AssetProfile> {
return this.http.get<AssetProfile>(`/api/assetProfile/${assetProfileId}`, defaultHttpOptionsFromConfig(config));
}

4
ui-ngx/src/app/core/http/customer.service.ts

@ -41,6 +41,10 @@ export class CustomerService {
return this.http.get<Customer>(`/api/customer/${customerId}`, defaultHttpOptionsFromConfig(config));
}
public getCustomersByIds(customerIds: Array<string>, config?: RequestConfig): Observable<Array<Customer>> {
return this.http.get<Array<Customer>>(`/api/customers?customerIds=${customerIds.join(',')}`, defaultHttpOptionsFromConfig(config));
}
public saveCustomer(customer: Customer, config?: RequestConfig): Observable<Customer>;
public saveCustomer(customer: Customer, saveParams: SaveEntityParams, config?: RequestConfig): Observable<Customer>;
public saveCustomer(customer: Customer, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable<Customer> {

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

@ -83,6 +83,11 @@ export class DashboardService {
return this.http.get<DashboardInfo>(`/api/dashboard/info/${dashboardId}`, defaultHttpOptionsFromConfig(config));
}
public getDashboards(dashboardIds: string[], config?: RequestConfig): Observable<Array<DashboardInfo>> {
return this.http.get<Array<DashboardInfo>>(`/api/dashboards?dashboardIds=${dashboardIds.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
public saveDashboard(dashboard: Dashboard, config?: RequestConfig): Observable<Dashboard> {
return this.http.post<Dashboard>('/api/dashboard', dashboard, defaultHttpOptionsFromConfig(config));
}

5
ui-ngx/src/app/core/http/device-profile.service.ts

@ -50,6 +50,11 @@ export class DeviceProfileService {
return this.http.get<PageData<DeviceProfile>>(`/api/deviceProfiles${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getDeviceProfilesByIds(deviceProfileIds: Array<string>, config?: RequestConfig): Observable<Array<DeviceProfileInfo>> {
return this.http.get<Array<DeviceProfileInfo>>(`/api/deviceProfileInfos?deviceProfileIds=${deviceProfileIds.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
public getDeviceProfile(deviceProfileId: string, config?: RequestConfig): Observable<DeviceProfile> {
return this.http.get<DeviceProfile>(`/api/deviceProfile/${deviceProfileId}`, defaultHttpOptionsFromConfig(config));
}

5
ui-ngx/src/app/core/http/entity-view.service.ts

@ -48,6 +48,11 @@ export class EntityViewService {
return this.http.get<EntityView>(`/api/entityView/${entityViewId}`, defaultHttpOptionsFromConfig(config));
}
public getEntityViews(entityViewIds: Array<string>, config?: RequestConfig): Observable<Array<EntityView>> {
return this.http.get<Array<EntityView>>(`/api/entityViews?entityViewIds=${entityViewIds.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
public getEntityViewInfo(entityViewId: string, config?: RequestConfig): Observable<EntityViewInfo> {
return this.http.get<EntityViewInfo>(`/api/entityView/info/${entityViewId}`, defaultHttpOptionsFromConfig(config));
}

36
ui-ngx/src/app/core/http/entity.service.ts

@ -245,50 +245,34 @@ export class EntityService {
observable = this.edgeService.getEdges(entityIds, config);
break;
case EntityType.ENTITY_VIEW:
observable = this.getEntitiesByIdsObservable(
(id) => this.entityViewService.getEntityView(id, config),
entityIds);
observable = this.entityViewService.getEntityViews(entityIds, config);
break;
case EntityType.TENANT:
observable = this.getEntitiesByIdsObservable(
(id) => this.tenantService.getTenant(id, config),
entityIds);
observable = this.tenantService.getTenantsByIds(entityIds, config);
break;
case EntityType.CUSTOMER:
observable = this.getEntitiesByIdsObservable(
(id) => this.customerService.getCustomer(id, config),
entityIds);
observable = this.customerService.getCustomersByIds(entityIds, config);
break;
case EntityType.DASHBOARD:
observable = this.getEntitiesByIdsObservable(
(id) => this.dashboardService.getDashboardInfo(id, config),
entityIds);
observable = this.dashboardService.getDashboards(entityIds, config);
break;
case EntityType.USER:
observable = this.getEntitiesByIdsObservable(
(id) => this.userService.getUser(id, config),
entityIds);
observable = this.userService.getUsersByIds(entityIds, config);
break;
case EntityType.ALARM:
console.error('Get Alarm Entity is not implemented!');
break;
case EntityType.DEVICE_PROFILE:
observable = this.getEntitiesByIdsObservable(
(id) => this.deviceProfileService.getDeviceProfileInfo(id, config),
entityIds);
observable = this.deviceProfileService.getDeviceProfilesByIds(entityIds, config);
break;
case EntityType.TENANT_PROFILE:
observable = this.tenantProfileService.getTenantProfilesByIds(entityIds, config);
break;
case EntityType.ASSET_PROFILE:
observable = this.getEntitiesByIdsObservable(
(id) => this.assetProfileService.getAssetProfileInfo(id, config),
entityIds);
observable = this.assetProfileService.getAssetProfilesByIds(entityIds, config);
break;
case EntityType.WIDGETS_BUNDLE:
observable = this.getEntitiesByIdsObservable(
(id) => this.widgetService.getWidgetsBundle(id, config),
entityIds);
observable = this.widgetService.getWidgetsBundlesByIds(entityIds, config);
break;
case EntityType.NOTIFICATION_TARGET:
observable = this.notificationService.getNotificationTargetsByIds(entityIds, config);
@ -300,9 +284,7 @@ export class EntityService {
observable = this.oauth2Service.findTenantOAuth2ClientInfosByIds(entityIds, config);
break;
case EntityType.RULE_CHAIN:
observable = this.getEntitiesByIdsObservable(
(id) => this.ruleChainService.getRuleChain(id, config),
entityIds);
observable = this.ruleChainService.getRuleChainsByIds(entityIds, config);
break;
case EntityType.TB_RESOURCE:
observable = this.resourceService.getResourcesByIds(entityIds, config);

5
ui-ngx/src/app/core/http/rule-chain.service.ts

@ -68,6 +68,11 @@ export class RuleChainService {
defaultHttpOptionsFromConfig(config));
}
public getRuleChainsByIds(ruleChainIds: Array<string>, config?: RequestConfig): Observable<Array<RuleChain>> {
return this.http.get<Array<RuleChain>>(`/api/ruleChains?&ruleChainIds=${ruleChainIds.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
public getRuleChain(ruleChainId: string, config?: RequestConfig): Observable<RuleChain> {
return this.http.get<RuleChain>(`/api/ruleChain/${ruleChainId}`, defaultHttpOptionsFromConfig(config));
}

4
ui-ngx/src/app/core/http/tenant.service.ts

@ -35,6 +35,10 @@ export class TenantService {
return this.http.get<PageData<Tenant>>(`/api/tenants${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getTenantsByIds(tenantIds: Array<string>, config?: RequestConfig): Observable<Array<Tenant>> {
return this.http.get<Array<Tenant>>(`/api/tenants?tenantIds=${tenantIds.join(',')}`, defaultHttpOptionsFromConfig(config));
}
public getTenantInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<TenantInfo>> {
return this.http.get<PageData<TenantInfo>>(`/api/tenantInfos${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}

4
ui-ngx/src/app/core/http/user.service.ts

@ -61,6 +61,10 @@ export class UserService {
return this.http.get<User>(`/api/user/${userId}`, defaultHttpOptionsFromConfig(config));
}
public getUsersByIds(userIds: Array<string>, config?: RequestConfig): Observable<Array<User>> {
return this.http.get<Array<User>>(`/api/users?userIds=${userIds.join(',')}`, defaultHttpOptionsFromConfig(config));
}
public saveUser(user: User, sendActivationMail: boolean = false,
config?: RequestConfig): Observable<User> {
let url = '/api/user';

5
ui-ngx/src/app/core/http/widget.service.ts

@ -331,6 +331,11 @@ export class WidgetService {
this.widgetsInfoInMemoryCache.delete(fullFqn);
}
public getWidgetsBundlesByIds(widgetsBundleIds: Array<string>, config?: RequestConfig): Observable<Array<WidgetsBundle>> {
return this.http.get<Array<WidgetsBundle>>(`/api/widgetsBundles?widgetsBundleIds=${widgetsBundleIds.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
private loadWidgetsBundleCache(config?: RequestConfig): Observable<any> {
if (!this.allWidgetsBundles) {
if (!this.loadWidgetsBundleCacheSubject) {

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

@ -20,13 +20,13 @@
font-size: 14px;
font-weight: 500;
}
.no-data-found {
height: 50px;
font-size: 16px;
}
.filter-list {
overflow: auto;
max-height: 300px;
.no-data-found {
height: 50px;
font-size: 16px;
}
&-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);

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

@ -19,11 +19,9 @@
font-size: 14px;
font-weight: 500;
}
.predicate-list {
.no-data-found {
height: 50px;
font-size: 16px;
}
.no-data-found {
height: 50px;
font-size: 16px;
}
.key-filter-list-divider {

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

@ -27,10 +27,6 @@
}
}
}
.tb-primary-fill {
overflow: visible;
}
}
:host ::ng-deep {

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

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

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

@ -127,7 +127,7 @@
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide flex" formControlName="produceIntermediateResult">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.produce-intermediate-result-hint' | translate: {
time: (intermediateAggregationIntervalInSecForCF * 1000 | milliSecondsToTimeString )
time: (intermediateAggregationIntervalInSecForCF * 1000 | milliSecondsToTimeString: false: true )
} }}">
{{ 'calculated-fields.entity-aggregation.produce-intermediate-result' | translate }}
</div>

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

@ -56,6 +56,20 @@
<div tbTruncateWithTooltip>{{ AggFunctionTranslations.get(metric.function) | translate }}</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'argumentName'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="xs:hidden lt-md:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
{{ 'calculated-fields.metrics.argument-name' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let metric" class="xs:hidden lt-md:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
<div tbTruncateWithTooltip>{{ metric.input.key }}</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'filter'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 lt-md:hidden">
{{ 'calculated-fields.metrics.filtered' | translate }}

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

@ -116,7 +116,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu
ngOnInit() {
if (this.simpleMode) {
this.displayColumns = ['name', 'function', 'actions'];
this.displayColumns = ['name', 'function', 'argumentName', 'actions'];
}
}

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

@ -51,14 +51,9 @@
} @else {
<div class="flex items-start gap-3 xs:flex-col xs:items-stretch">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{
(outputForm.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate
}}
</mat-label>
<mat-label>{{ (outputForm.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key') | translate }}</mat-label>
<input matInput formControlName="name" required>
@if (outputForm.get('name').errors && outputForm.get('name').touched) {
<mat-error>

3
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html

@ -180,6 +180,9 @@
<div class="tb-form-panel-title" translate>widgets.time-series-chart.axis.y-axis</div>
<tb-time-series-chart-axis-settings
formControlName="yAxis"
[aliasController]="aliasController"
[dataKeyCallbacks]="callbacks"
[datasource]="datasource"
axisType="yAxis"
hideUnits
hideDecimals>

3
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html

@ -242,6 +242,9 @@
<div class="tb-form-panel-title" translate>widgets.time-series-chart.axis.y-axis</div>
<tb-time-series-chart-axis-settings
formControlName="yAxis"
[aliasController]="aliasController"
[dataKeyCallbacks]="callbacks"
[datasource]="datasource"
axisType="yAxis"
hideUnits
hideDecimals>

3
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html

@ -103,6 +103,9 @@
formControlName="states">
</tb-time-series-chart-states-panel>
<tb-time-series-chart-y-axes-panel
[aliasController]="aliasController"
[dataKeyCallbacks]="callbacks"
[datasource]="datasource"
formControlName="yAxes"
[supportsUnitConversion]="widgetConfig.typeParameters.supportsUnitConversion"
(axisRemoved)="yAxisRemoved($event)">

34
ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts

@ -381,8 +381,8 @@ export interface TimeSeriesChartYAxisSettings extends TimeSeriesChartAxisSetting
decimals?: number;
interval?: number;
splitNumber?: number;
min?: number | string;
max?: number | string;
min?: number | string | ValueSourceConfig;
max?: number | string | ValueSourceConfig;
ticksGenerator?: TimeSeriesChartTicksGenerator | string;
ticksFormatter?: TimeSeriesChartTicksFormatter | string;
}
@ -867,6 +867,9 @@ export interface TimeSeriesChartAxis {
id: string;
settings: TimeSeriesChartAxisSettings;
option: CartesianAxisOption;
minLatestDataKey?: DataKey;
maxLatestDataKey?: DataKey;
unitConvertor?: (value: number) => number;
}
export interface TimeSeriesChartYAxis extends TimeSeriesChartAxis {
@ -926,6 +929,29 @@ export const createTimeSeriesYAxis = (units: string,
return ticks?.filter(tick => tick.value >= extent[0] && tick.value <= extent[1]);
};
}
let initialMin: number | string | undefined;
if (isDefinedAndNotNull(settings.min)) {
if (typeof settings.min === 'object' && 'type' in settings.min) {
initialMin = undefined;
} else if (typeof settings.min === 'number') {
initialMin = unitConvertor ? unitConvertor(settings.min) : settings.min;
} else if (typeof settings.min === 'string') {
initialMin = settings.min;
}
}
let initialMax: number | string | undefined;
if (isDefinedAndNotNull(settings.max)) {
if (typeof settings.max === 'object' && 'type' in settings.max) {
initialMax = undefined;
} else if (typeof settings.max === 'number') {
initialMax = unitConvertor ? unitConvertor(settings.max) : settings.max;
} else if (typeof settings.max === 'string') {
initialMax = settings.max;
}
}
return {
id: settings.id,
decimals,
@ -939,8 +965,8 @@ export const createTimeSeriesYAxis = (units: string,
offset: 0,
alignTicks: true,
scale: true,
min: isDefinedAndNotNull(settings.min) ? unitConvertor(Number(settings.min)) : settings.min,
max: isDefinedAndNotNull(settings.max) ? unitConvertor(Number(settings.max)) : settings.max,
min: initialMin,
max: initialMax,
minInterval,
splitNumber,
interval,

177
ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts

@ -54,13 +54,15 @@ import {
getFocusedSeriesIndex,
measureAxisNameSize
} from '@home/components/widget/lib/chart/echarts-widget.models';
import { DateFormatProcessor, ValueSourceType } from '@shared/models/widget-settings.models';
import { DateFormatProcessor, ValueSourceConfig, ValueSourceType } from '@shared/models/widget-settings.models';
import {
formattedDataFormDatasourceData,
formatValue,
isDefined,
isDefinedAndNotNull,
isEqual,
isNumber,
isString,
mergeDeep
} from '@core/utils';
import { DataKey, Datasource, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models';
@ -286,9 +288,36 @@ export class TbTimeSeriesChart {
}
}
}
for (const yAxis of this.yAxisList) {
const minType = (yAxis.settings.min as ValueSourceConfig).type;
const maxType = (yAxis.settings.max as ValueSourceConfig).type;
if (minType === ValueSourceType.latestKey && yAxis.minLatestDataKey) {
const data = this.ctx.latestData.find(d => d.dataKey === yAxis.minLatestDataKey);
if (data?.data[0]) {
const value = this.parseAxisLimitData(data.data[0][1], yAxis.unitConvertor);
if (yAxis.option.min !== value) {
yAxis.option.min = value;
update = true;
}
}
}
if (maxType === ValueSourceType.latestKey && yAxis.maxLatestDataKey) {
const data = this.ctx.latestData.find(d => d.dataKey === yAxis.maxLatestDataKey);
if (data?.data[0]) {
const value = this.parseAxisLimitData(data.data[0][1], yAxis.unitConvertor);
if (yAxis.option.max !== value) {
yAxis.option.max = value;
update = true;
}
}
}
}
}
if (this.timeSeriesChart && update) {
this.updateSeriesData();
this.updateAxisLimits();
}
}
@ -550,6 +579,7 @@ export class TbTimeSeriesChart {
private setupYAxes(): void {
const yAxisSettingsList = Object.values(this.settings.yAxes);
yAxisSettingsList.sort((a1, a2) => a1.order - a2.order);
const axisLimitDatasources: Datasource[] = [];
for (const yAxisSettings of yAxisSettingsList) {
const axisSettings = mergeDeep<TimeSeriesChartYAxisSettings>({} as TimeSeriesChartYAxisSettings,
defaultTimeSeriesChartYAxisSettings, yAxisSettings);
@ -563,8 +593,81 @@ export class TbTimeSeriesChart {
axisSettings.ticksFormatter = this.stateValueConverter.ticksFormatter;
}
const yAxis = createTimeSeriesYAxis(unitSymbol, decimals, axisSettings, this.ctx.utilsService, this.darkMode, unitConvertor);
if (isDefinedAndNotNull(axisSettings.min)) {
this.processYAxisLimit(axisSettings.min as ValueSourceConfig, 'min', yAxis, axisLimitDatasources, unitConvertor);
}
if (isDefinedAndNotNull(axisSettings.max)) {
this.processYAxisLimit(axisSettings.max as ValueSourceConfig, 'max', yAxis, axisLimitDatasources, unitConvertor);
}
this.yAxisList.push(yAxis);
}
this.subscribeForAxisLimits(axisLimitDatasources);
}
private processYAxisLimit(
limit: ValueSourceConfig,
limitType: 'min' | 'max',
yAxis: TimeSeriesChartYAxis,
axisLimitDatasources: Datasource[],
unitConvertor?: (value: number) => number
): void {
if (limit && typeof limit === 'object' && 'type' in limit) {
if (limit.type === ValueSourceType.latestKey) {
let latestDataKey: DataKey = null;
if (this.ctx.datasources.length) {
for (const datasource of this.ctx.datasources) {
latestDataKey = datasource.latestDataKeys?.find(d =>
(d.type === DataKeyType.function && d.label === limit.latestKey) ||
(d.type !== DataKeyType.function && d.name === limit.latestKey &&
d.type === limit.latestKeyType));
if (latestDataKey) {
break;
}
}
}
if (latestDataKey) {
if (limitType === 'min') {
yAxis.minLatestDataKey = latestDataKey;
} else {
yAxis.maxLatestDataKey = latestDataKey;
}
}
} else if (limit.type === ValueSourceType.entity) {
const entityAliasId = this.ctx.aliasController.getEntityAliasId(limit.entityAlias);
if (entityAliasId) {
let datasource = axisLimitDatasources.find(d => d.entityAliasId === entityAliasId);
const entityDataKey: DataKey = {
type: limit.entityKeyType,
name: limit.entityKey,
label: limit.entityKey,
settings: {
yAxisId: yAxis.id,
axisLimit: limitType
}
};
if (datasource) {
datasource.dataKeys.push(entityDataKey);
} else {
datasource = {
type: DatasourceType.entity,
name: limit.entityAlias,
aliasName: limit.entityAlias,
entityAliasId,
dataKeys: [entityDataKey]
};
axisLimitDatasources.push(datasource);
}
}
} else if (limit.type === ValueSourceType.constant) {
const value = unitConvertor ? unitConvertor(limit.value) : limit.value;
if (limitType === 'min') {
yAxis.option.min = value;
} else {
yAxis.option.max = value;
}
}
return;
}
}
private setupVisualMap(): void {
@ -622,6 +725,50 @@ export class TbTimeSeriesChart {
}
}
private subscribeForAxisLimits(datasources: Datasource[]) {
if (datasources.length) {
const axisLimitsSubscriptionOptions: WidgetSubscriptionOptions = {
datasources,
useDashboardTimewindow: false,
type: widgetType.latest,
callbacks: {
onDataUpdated: (subscription) => {
let update = false;
if (subscription.data) {
for (const yAxis of this.yAxisList) {
for (const data of subscription.data) {
if (data.dataKey.settings?.yAxisId === yAxis.id) {
const limitType = data.dataKey.settings.axisLimit as ('min' | 'max');
if (data.data[0]) {
const value = this.parseAxisLimitData(data.data[0][1], yAxis.unitConvertor);
if (isDefinedAndNotNull(value)) {
if (limitType === 'min') {
if (yAxis.option.min !== value) {
yAxis.option.min = value;
update = true;
}
} else {
if (yAxis.option.max !== value) {
yAxis.option.max = value;
update = true;
}
}
}
}
}
}
}
}
if (this.timeSeriesChart && update) {
this.updateAxisLimits();
}
}
}
};
this.ctx.subscriptionApi.createSubscription(axisLimitsSubscriptionOptions, true).subscribe();
}
}
private drawChart() {
echartsModule.init();
this.renderer.setStyle(this.chartElement, 'letterSpacing', 'normal');
@ -714,6 +861,24 @@ export class TbTimeSeriesChart {
}
}
private parseAxisLimitData = (data: any, unitConvertor?: (value: number) => number): number => {
let value: number;
if (isDefinedAndNotNull(data)) {
if (isNumber(data)) {
value = data;
} else if (isString(data)) {
value = Number(data);
}
}
if (isDefinedAndNotNull(value) && !isNaN(value)) {
if (unitConvertor) {
return unitConvertor(value);
}
return value;
}
return null;
};
private updateSeries(): void {
this.timeSeriesChartOptions.series = generateChartData(this.dataItems, this.thresholdItems,
this.stackMode,
@ -836,6 +1001,16 @@ export class TbTimeSeriesChart {
return changed;
}
private updateAxisLimits(): void {
if (this.timeSeriesChart && !this.timeSeriesChart.isDisposed()) {
this.timeSeriesChartOptions.yAxis = this.yAxisList.map(axis => axis.option);
this.timeSeriesChart.setOption(this.timeSeriesChartOptions, {
replaceMerge: ['yAxis']
});
this.updateAxes();
}
}
private scaleYAxis(yAxis: TimeSeriesChartYAxis): boolean {
if (!this.stateData) {
const axisBarDataItems = this.dataItems.filter(d => d.yAxisId === yAxis.id && d.enabled &&

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html

@ -91,6 +91,9 @@
<div class="tb-form-panel-title" translate>widgets.time-series-chart.axis.y-axis</div>
<tb-time-series-chart-axis-settings
formControlName="yAxis"
[aliasController]="aliasController"
[dataKeyCallbacks]="callbacks"
[datasource]="datasource"
axisType="yAxis"
advanced
hideUnits

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html

@ -169,6 +169,9 @@
<div class="tb-form-panel-title" translate>widgets.time-series-chart.axis.y-axis</div>
<tb-time-series-chart-axis-settings
formControlName="yAxis"
[aliasController]="aliasController"
[dataKeyCallbacks]="callbacks"
[datasource]="datasource"
axisType="yAxis"
hideUnits
hideDecimals>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html

@ -62,6 +62,9 @@
</tb-time-series-chart-states-panel>
<tb-time-series-chart-y-axes-panel
formControlName="yAxes"
[aliasController]="aliasController"
[dataKeyCallbacks]="dataKeyCallbacks"
[datasource]="datasource"
(axisRemoved)="yAxisRemoved($event)"
[supportsUnitConversion]="widgetConfig.typeParameters.supportsUnitConversion"
advanced>

71
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.html

@ -0,0 +1,71 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div [formGroup]="limitForm" class="tb-form-table-row overflow-hidden items-start">
<div class="flex-shrink capitalize" [class]="isPanelView ? 'basis-10' : 'basis-1/5'">
{{ labelKey | translate}}
</div>
<div class="flex-grow flex-shrink flex flex-col gap-2 gt-sm:flex-row gt-sm:gap-3" [class]="isPanelView ? 'basis-1/2' : 'basis-40%'">
<mat-form-field class="tb-inline-field flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
<mat-option *ngFor="let type of ValueSourceTypes" [value]="type">
{{ ValueSourceTypeTranslation.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-entity-alias-input
required
class="flex-1"
*ngIf="limitForm.get('type').value === ValueSourceType.entity"
[aliasController]="aliasController"
formControlName="entityAlias">
</tb-entity-alias-input>
</div>
<div class="flex-grow flex-shrink max-w-5/12" [class]="isPanelView ? 'basis-1/2' : 'basis-40%'">
<mat-form-field *ngIf="limitForm.get('type').value === ValueSourceType.constant"
appearance="outline" class="tb-inline-field !w-full number" subscriptSizing="dynamic"
[class.tb-suffix-absolute]="!limitForm.get('value').value">
<input matInput formControlName="value" type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
</mat-form-field>
<tb-data-key-input
required
*ngIf="limitForm.get('type').value === ValueSourceType.latestKey"
requiredText="widgets.chart.chart-axis.key-required"
[datasourceType]="datasource?.type"
[entityAliasId]="datasource?.entityAliasId"
[deviceId]="datasource?.deviceId"
[aliasController]="aliasController"
[dataKeyType]="datasource?.type === DatasourceType.function ? DataKeyType.function : null"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="callbacks"
[editable]="false"
[formControl]="latestKeyFormControl">
</tb-data-key-input>
<tb-data-key-input
required
*ngIf="limitForm.get('type').value === ValueSourceType.entity"
requiredText="widgets.chart.chart-axis.entity-key-required"
[datasourceType]="DatasourceType.entity"
[aliasController]="aliasController"
[entityAlias]="limitForm.get('entityAlias').value"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="callbacks"
[editable]="false"
[formControl]="entityKeyFormControl">
</tb-data-key-input>
</div>
</div>

209
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts

@ -0,0 +1,209 @@
///
/// 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import {
ControlValueAccessor, NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder, UntypedFormControl,
UntypedFormGroup, ValidationErrors, Validator,
Validators,
} from '@angular/forms';
import {
ValueSourceConfig,
ValueSourceType,
ValueSourceTypes,
ValueSourceTypeTranslation
} from '@shared/models/widget-settings.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataKey, Datasource, DatasourceType, } from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { IAliasController } from '@core/api/widget-api.models';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
import { merge } from 'rxjs';
@Component({
selector: 'tb-axis-scale-row',
templateUrl: './axis-scale-row.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AxisScaleRowComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AxisScaleRowComponent),
multi: true
},
]
})
export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Validator {
@Input()
isPanelView = false;
@Input()
aliasController: IAliasController;
@Input()
callbacks: DataKeysCallbacks;
@Input()
datasource: Datasource;
@Input()
labelKey: string;
ValueSourceType = ValueSourceType;
DataKeyType = DataKeyType;
DatasourceType = DatasourceType;
ValueSourceTypeTranslation = ValueSourceTypeTranslation;
ValueSourceTypes = ValueSourceTypes;
limitForm: UntypedFormGroup;
latestKeyFormControl: UntypedFormControl;
entityKeyFormControl: UntypedFormControl;
private propagateChanges: (value: any) => void = () => {};
private modelValue: ValueSourceConfig | null = null;
constructor(private fb: UntypedFormBuilder,
private destroyRef: DestroyRef) {
}
ngOnInit() {
this.limitForm = this.fb.group({
type: [ValueSourceType.constant],
value: [null],
entityAlias: [null]
});
this.latestKeyFormControl = this.fb.control(null, [Validators.required]);
this.entityKeyFormControl = this.fb.control(null, [Validators.required]);
merge(
this.latestKeyFormControl.valueChanges,
this.entityKeyFormControl.valueChanges,
this.limitForm.valueChanges
).pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.updateValidators();
this.updateModel();
});
}
writeValue(value: ValueSourceConfig) {
this.modelValue = value;
this.limitForm.patchValue(
{
type: value.type || ValueSourceType.constant,
value: value.value,
entityAlias: value.entityAlias,
}, {emitEvent: false}
);
if (value.type === ValueSourceType.latestKey) {
this.latestKeyFormControl.patchValue({
type: value.latestKeyType,
name: value.latestKey
}, {emitEvent: false});
} else if (value.type === ValueSourceType.entity) {
this.entityKeyFormControl.patchValue({
type: value.entityKeyType,
name: value.entityKey
}, {emitEvent: false});
}
this.updateValidators();
this.limitForm.markAllAsTouched();
}
registerOnChange(fn: any) {
this.propagateChanges = fn;
}
registerOnTouched(fn: any) {
}
validate(): ValidationErrors | null {
const type = this.limitForm.get('type')?.value;
const errors: any = {};
if (this.limitForm.invalid) {
errors.form = false;
}
if (type === ValueSourceType.latestKey) {
if (!this.latestKeyFormControl.value || this.latestKeyFormControl.invalid) {
errors.latestKey = false;
}
} else if (type === ValueSourceType.entity) {
if (!this.limitForm.get('entityAlias')?.value) {
errors.entityAlias = false;
}
if (!this.entityKeyFormControl.value || this.entityKeyFormControl.invalid) {
errors.entityKey = false;
}
}
return Object.keys(errors).length ? { axisLimitForm: errors } : null;
}
private updateValidators() {
const axisTypeControl = this.limitForm.get('type');
if (axisTypeControl && this.entityKeyFormControl && this.latestKeyFormControl) {
const type = axisTypeControl.value;
if (type === ValueSourceType.latestKey) {
this.latestKeyFormControl.setValidators([Validators.required]);
this.entityKeyFormControl.clearValidators();
} else if (type === ValueSourceType.entity) {
this.latestKeyFormControl.clearValidators();
this.limitForm.get('entityAlias').setValidators([Validators.required]);
this.entityKeyFormControl.setValidators([Validators.required]);
} else {
this.latestKeyFormControl.clearValidators();
this.entityKeyFormControl.clearValidators();
}
this.latestKeyFormControl.updateValueAndValidity({ emitEvent: false });
this.entityKeyFormControl.updateValueAndValidity({ emitEvent: false });
}
}
private updateModel() {
const value = this.limitForm.value;
const type = value.type;
let updates: Partial<ValueSourceConfig> = { type };
if (type === ValueSourceType.latestKey) {
const latestKey: DataKey = this.latestKeyFormControl.value;
updates.latestKey = latestKey?.name;
updates.latestKeyType = latestKey?.type as any;
} else if (type === ValueSourceType.entity) {
const entityKey: DataKey = this.entityKeyFormControl.value;
updates.entityKey = entityKey?.name;
updates.entityKeyType = entityKey?.type as any;
updates.entityAlias = value?.entityAlias;
} else {
updates.value = value?.value;
}
this.modelValue = updates as ValueSourceConfig;
this.propagateChanges(this.modelValue);
}
}

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.html

@ -19,6 +19,9 @@
<div class="tb-axis-settings-title">{{ panelTitle }}</div>
<div class="tb-axis-settings-panel-content">
<tb-time-series-chart-axis-settings
[aliasController]="aliasController"
[dataKeyCallbacks]="dataKeyCallbacks"
[datasource]="datasource"
formControlName="axis"
alwaysExpanded
[advanced]="advanced"

12
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.ts

@ -22,6 +22,9 @@ import {
TimeSeriesChartYAxisSettings
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { coerceBoolean } from '@shared/decorators/coercion';
import { IAliasController } from '@core/api/widget-api.models';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
import { Datasource } from '@shared/models/widget.models';
@Component({
selector: 'tb-time-series-chart-axis-settings-panel',
@ -32,6 +35,15 @@ import { coerceBoolean } from '@shared/decorators/coercion';
})
export class TimeSeriesChartAxisSettingsPanelComponent implements OnInit {
@Input()
aliasController: IAliasController;
@Input()
dataKeyCallbacks: DataKeysCallbacks;
@Input()
datasource: Datasource;
@Input()
axisType: 'xAxis' | 'yAxis' = 'xAxis';

48
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html

@ -157,24 +157,38 @@
type="number" min="1" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
</mat-form-field>
</div>
<div *ngIf="axisType === 'yAxis'" class="tb-form-panel stroked">
<div class="flex flex-col gap-2">
<div translate>widgets.chart.chart-axis.scale-limits</div>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell" [class]="alwaysExpanded ? 'basis-10' : 'basis-1/5'" translate>widgets.chart.chart-axis.limit</div>
<div class="tb-form-table-header-cell flex-grow flex-shrink" [class]="alwaysExpanded ? 'basis-1/2' : 'basis-40%'" translate>widgets.chart.chart-axis.source</div>
<div class="tb-form-table-header-cell flex-grow flex-shrink" [class]="alwaysExpanded ? 'basis-1/2' : 'basis-40%'" translate>widgets.chart.chart-axis.key-value</div>
</div>
<div class="tb-form-table-body">
<tb-axis-scale-row
[isPanelView]="alwaysExpanded"
[datasource]="datasource"
[callbacks]="dataKeyCallbacks"
[aliasController]="aliasController"
formControlName="min"
labelKey="widgets.chart.chart-axis.scale-min">
</tb-axis-scale-row>
<mat-divider class="mt-2 gt-sm:hidden"></mat-divider>
<tb-axis-scale-row
[isPanelView]="alwaysExpanded"
[datasource]="datasource"
[callbacks]="dataKeyCallbacks"
[aliasController]="aliasController"
formControlName="max"
labelKey="widgets.chart.chart-axis.scale-max">
</tb-axis-scale-row>
</div>
</div>
</div>
</div>
</ng-template>
</mat-expansion-panel>
<div *ngIf="axisType === 'yAxis'" class="tb-form-panel no-border no-padding-top">
<div class="tb-form-row space-between column-xs">
<div translate>widgets.chart.chart-axis.scale</div>
<div class="flex flex-row items-center justify-start gap-2">
<div class="tb-small-label" translate>widgets.chart.chart-axis.scale-min</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="min"
type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
</mat-form-field>
<div class="tb-small-label" translate>widgets.chart.chart-axis.scale-max</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="max"
type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
</mat-form-field>
</div>
</div>
</div>
</div>
</ng-container>

27
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts

@ -17,9 +17,11 @@
import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors, Validator,
Validators
} from '@angular/forms';
import {
@ -32,6 +34,9 @@ import { merge } from 'rxjs';
import { coerceBoolean } from '@shared/decorators/coercion';
import { WidgetService } from '@core/http/widget.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IAliasController } from '@app/core/public-api';
import { Datasource } from '@app/shared/public-api';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
@Component({
selector: 'tb-time-series-chart-axis-settings',
@ -42,10 +47,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent),
multi: true
}
]
})
export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValueAccessor {
export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValueAccessor, Validator {
@Input()
@coerceBoolean()
@ -61,6 +71,15 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu
defaultXAxisTicksFormat = defaultXAxisTicksFormat;
@Input()
aliasController: IAliasController;
@Input()
dataKeyCallbacks: DataKeysCallbacks;
@Input()
datasource: Datasource;
@Input()
disabled: boolean;
@ -147,6 +166,12 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu
registerOnTouched(_fn: any): void {
}
validate(): ValidationErrors | null {
return this.axisSettingsFormGroup.valid ? null : {
axisSettings: false
};
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {

117
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts

@ -38,7 +38,8 @@ import {
import {
defaultTimeSeriesChartYAxisSettings,
getNextTimeSeriesYAxisId,
TimeSeriesChartYAxes, TimeSeriesChartYAxisId,
TimeSeriesChartYAxes,
TimeSeriesChartYAxisId,
TimeSeriesChartYAxisSettings,
timeSeriesChartYAxisValid,
timeSeriesChartYAxisValidator
@ -47,6 +48,9 @@ import { mergeDeep } from '@core/utils';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { coerceBoolean } from '@shared/decorators/coercion';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IAliasController } from '@app/core/public-api';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
import { DataKey, DataKeyType, Datasource, ValueSourceConfig, ValueSourceType } from '@app/shared/public-api';
@Component({
selector: 'tb-time-series-chart-y-axes-panel',
@ -68,6 +72,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
})
export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, OnInit, Validator {
@Input()
aliasController: IAliasController;
@Input()
dataKeyCallbacks: DataKeysCallbacks;
@Input()
datasource: Datasource;
@Input()
disabled: boolean;
@ -113,6 +126,7 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
for (const axis of axes) {
yAxes[axis.id] = axis;
}
this.updateLatestDataKeys(Object.values(yAxes));
this.propagateChange(yAxes);
}
);
@ -135,7 +149,7 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
}
writeValue(value: TimeSeriesChartYAxes | undefined): void {
const yAxes: TimeSeriesChartYAxes = value || {};
const yAxes: TimeSeriesChartYAxes = this.checkLatestDataKeys(value || {});
if (!yAxes.default) {
yAxes.default = mergeDeep({} as TimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings,
{id: 'default', order: 0} as TimeSeriesChartYAxisSettings);
@ -182,6 +196,8 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
const axes: TimeSeriesChartYAxisSettings[] = this.yAxesFormGroup.get('axes').value;
axis.id = getNextTimeSeriesYAxisId(axes);
axis.order = axes.length;
axis.min = this.normalizeAxisLimit(axis.min);
axis.max = this.normalizeAxisLimit(axis.max);
const axesArray = this.yAxesFormGroup.get('axes') as UntypedFormArray;
const axisControl = this.fb.control(axis, [timeSeriesChartYAxisValidator]);
axesArray.push(axisControl);
@ -194,4 +210,101 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
});
return this.fb.array(axesControls);
}
private checkLatestDataKeys(yAxes: TimeSeriesChartYAxes): TimeSeriesChartYAxes {
const latestKeys = this.datasource?.latestDataKeys || [];
const result: TimeSeriesChartYAxes = {};
for (const [id, axis] of Object.entries(yAxes)) {
axis.min = this.normalizeAxisLimit(axis.min);
axis.max = this.normalizeAxisLimit(axis.max);
const minCfg = axis.min;
const maxCfg = axis.max;
const minValid = !!minCfg && (
minCfg.type !== ValueSourceType.latestKey ||
latestKeys.some(k => this.isYAxisKey(k, minCfg))
);
const maxValid = !!maxCfg && (
maxCfg.type !== ValueSourceType.latestKey ||
latestKeys.some(k => this.isYAxisKey(k, maxCfg))
);
if (minValid && maxValid) {
result[id] = axis;
}
}
return result;
}
private updateLatestDataKeys(yAxes: TimeSeriesChartYAxisSettings[]) {
if (this.datasource) {
let latestKeys = this.datasource.latestDataKeys;
if (!latestKeys) {
latestKeys = [];
this.datasource.latestDataKeys = latestKeys;
}
const existingYAxisKeys = latestKeys.filter(k => k.settings?.__yAxisMinKey === true || k.settings?.__yAxisMaxKey === true);
const foundYAxisKeys: DataKey[] = [];
for(const yAxis of yAxes) {
const min = yAxis.min as ValueSourceConfig;
const max = yAxis.max as ValueSourceConfig;
if (min.type === ValueSourceType.latestKey) {
const found = existingYAxisKeys.find(k => this.isYAxisKey(k, min));
if (!found) {
const newKey = this.dataKeyCallbacks.generateDataKey(min.latestKey, min.latestKeyType,
null, true, null);
newKey.settings.__yAxisMinKey = true;
latestKeys.push(newKey);
} else if (foundYAxisKeys.indexOf(found) === -1) {
foundYAxisKeys.push(found);
}
}
if (max.type === ValueSourceType.latestKey) {
const found = existingYAxisKeys.find(k => this.isYAxisKey(k, max));
if (!found) {
const newKey = this.dataKeyCallbacks.generateDataKey(max.latestKey, max.latestKeyType,
null, true, null);
newKey.settings.__yAxisMaxKey = true;
latestKeys.push(newKey);
} else if (foundYAxisKeys.indexOf(found) === -1) {
foundYAxisKeys.push(found);
}
}
}
const toRemove = existingYAxisKeys.filter(k => foundYAxisKeys.indexOf(k) === -1);
for (const key of toRemove) {
const index = latestKeys.indexOf(key);
if (index > -1) {
latestKeys.splice(index, 1);
}
}
}
}
private isYAxisKey(d: DataKey, limit: ValueSourceConfig): boolean {
return (d.type === DataKeyType.function && d.label === limit.latestKey) ||
(d.type !== DataKeyType.function && d.name === limit.latestKey &&
d.type === limit.latestKeyType);
}
private normalizeAxisLimit(limit: string | number | ValueSourceConfig): ValueSourceConfig {
if (!limit) {
return {
type: ValueSourceType.constant,
value: null,
entityAlias: null
};
} else if (typeof limit === 'number' || typeof limit === 'string') {
return {
type: ValueSourceType.constant,
value: Number(limit),
entityAlias: null
};
}
return limit;
}
}

8
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html

@ -30,14 +30,14 @@
</mat-option>
</mat-select>
</mat-form-field>
<div class="tb-min-field">
<div class="tb-min-field" formGroupName="min">
<mat-form-field appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic">
<input matInput formControlName="min" type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
<input matInput formControlName="value" type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
</mat-form-field>
</div>
<div class="tb-max-field">
<div class="tb-max-field" formGroupName="max">
<mat-form-field appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic">
<input matInput formControlName="max" type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
<input matInput formControlName="value" type="number" placeholder="{{ 'widgets.chart.chart-axis.scale-auto' | translate }}">
</mat-form-field>
</div>
<div class="tb-units-field">

88
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts

@ -42,6 +42,8 @@ import {
import { deepClone } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TimeSeriesChartYAxesPanelComponent } from '@home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component';
import { ValueSourceType } from '@shared/models/widget-settings.models';
@Component({
selector: 'tb-time-series-chart-y-axis-row',
@ -85,6 +87,7 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
constructor(private fb: UntypedFormBuilder,
private translate: TranslateService,
private popoverService: TbPopoverService,
private timeSeriesChartYAxesPanel: TimeSeriesChartYAxesPanelComponent,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef,
@ -97,8 +100,8 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
position: [null, []],
units: [null, []],
decimals: [null, []],
min: [null, []],
max: [null, []],
min: this.createLimitFormGroup(),
max: this.createLimitFormGroup(),
show: [null, []]
});
this.axisFormGroup.valueChanges.pipe(
@ -132,17 +135,19 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
writeValue(value: TimeSeriesChartYAxisSettings): void {
this.modelValue = value;
this.axisFormGroup.patchValue(
{
label: value.label,
position: value.position,
units: value.units,
decimals: value.decimals,
min: value.min,
max: value.max,
show: value.show,
}, {emitEvent: false}
);
const min = this.normalizeLimit(value.min);
const max = this.normalizeLimit(value.max);
this.axisFormGroup.patchValue({
label: value.label,
position: value.position,
units: value.units,
decimals: value.decimals,
min,
max,
show: value.show,
}, { emitEvent: false });
this.updateValidators();
this.cd.markForCheck();
}
@ -165,7 +170,10 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
axisType: 'yAxis',
panelTitle: this.translate.instant('widgets.time-series-chart.axis.y-axis-settings'),
axisSettings: deepClone(this.modelValue),
advanced: this.advanced
advanced: this.advanced,
aliasController: this.timeSeriesChartYAxesPanel.aliasController,
dataKeyCallbacks: this.timeSeriesChartYAxesPanel.dataKeyCallbacks,
datasource: this.timeSeriesChartYAxesPanel.datasource
},
isModal: true
});
@ -190,6 +198,10 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
}
}
checkIsConstantLimit(limit: 'min' | 'max') {
return this.axisFormGroup.get(`${limit}.type`)?.value === ValueSourceType.constant;
}
private updateValidators() {
const show: boolean = this.axisFormGroup.get('show').value;
if (show) {
@ -203,10 +215,21 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
this.axisFormGroup.get('units').disable({emitEvent: false});
this.axisFormGroup.get('decimals').disable({emitEvent: false});
}
if(!this.checkIsConstantLimit('min')){
this.axisFormGroup.get('min').disable({emitEvent: false});
} else {
this.axisFormGroup.get('min').enable({emitEvent: false});
}
if(!this.checkIsConstantLimit('max')){
this.axisFormGroup.get('max').disable({emitEvent: false});
} else {
this.axisFormGroup.get('max').enable({emitEvent: false});
}
}
private updateModel() {
const value = this.axisFormGroup.value;
const value = this.axisFormGroup.getRawValue();
this.modelValue.label = value.label;
this.modelValue.position = value.position;
this.modelValue.units = value.units;
@ -216,4 +239,39 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
this.modelValue.show = value.show;
this.propagateChange(this.modelValue);
}
private createLimitFormGroup() {
return this.fb.group({
type: [ValueSourceType.constant, []],
value: [null, []],
latestKey: [null, []],
latestKeyType: [null, []],
entityAlias: [null, []],
entityKey: [null, []],
entityKeyType: [null, []]
});
}
private normalizeLimit(limit: any) {
const base = {
type: ValueSourceType.constant,
value: null,
latestKey: null,
latestKeyType: null,
entityAlias: null,
entityKey: null,
entityKeyType: null
};
if (limit == null) return base;
if (typeof limit === 'number' || typeof limit === 'string') {
return { ...base, type: ValueSourceType.constant, value: Number(limit) };
}
return {
...base,
...limit,
};
}
}

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts

@ -164,7 +164,7 @@ export class ColorSettingsComponent implements OnInit, ControlValueAccessor, OnD
renderer: this.renderer,
componentType: ColorSettingsPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: 'left',
preferredPlacement: ['leftTopOnly', 'leftOnly', 'leftBottomOnly'],
context: {
colorSettings: this.modelValue,
settingsComponents: this.colorSettingsComponentService.getOtherColorSettingsComponents(this),

7
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -267,6 +267,7 @@ import {
import {
ShapeFillStripeSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component';
import { AxisScaleRowComponent } from './axis-scale-row.component';
@NgModule({
declarations: [
@ -372,7 +373,8 @@ import {
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
WidgetSettingsComponent
WidgetSettingsComponent,
AxisScaleRowComponent
],
imports: [
CommonModule,
@ -453,7 +455,8 @@ import {
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
WidgetSettingsComponent
WidgetSettingsComponent,
AxisScaleRowComponent
],
providers: [
ColorSettingsComponentService,

4
ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts

@ -236,9 +236,9 @@ export class EntityTableConfig<T extends BaseData<HasId>, P extends PageLink = P
this.table = null;
}
updateData(closeDetails = false) {
updateData(closeDetails = false, reloadEntity = true) {
if (this.table) {
this.table.updateData(closeDetails);
this.table.updateData(closeDetails, reloadEntity);
} else if (this.entityDetailsPage) {
this.entityDetailsPage.reload();
}

2
ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts

@ -68,7 +68,7 @@ export interface IEntitiesTableComponent {
addEnabled(): boolean;
clearSelection(): void;
updateData(closeDetails?: boolean): void;
updateData(closeDetails?: boolean, reloadEntity?: boolean): void;
onRowClick($event: Event, entity): void;
toggleEntityDetails($event: Event, entity);
addEntity($event: Event): void;

2
ui-ngx/src/app/modules/home/pages/asset/asset.component.ts

@ -100,6 +100,6 @@ export class AssetComponent extends EntityComponent<AssetInfo> {
}
onAssetProfileUpdated() {
this.entitiesTableConfig.updateData(false);
this.entitiesTableConfig.updateData(false, false);
}
}

2
ui-ngx/src/app/modules/home/pages/device/device.component.ts

@ -142,7 +142,7 @@ export class DeviceComponent extends EntityComponent<DeviceInfo> {
}
onDeviceProfileUpdated() {
this.entitiesTableConfig.updateData(false);
this.entitiesTableConfig.updateData(false, false);
}
onDeviceProfileChanged(deviceProfile: DeviceProfileInfo) {

2
ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts

@ -101,6 +101,6 @@ export class TenantComponent extends ContactBasedComponent<TenantInfo> {
}
onTenantProfileUpdated() {
this.entitiesTableConfig.updateData(false);
this.entitiesTableConfig.updateData(false, false);
}
}

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

@ -15,57 +15,51 @@
limitations under the License.
-->
<div class="tb-create-password-content mat-app-background tb-dark flex flex-row items-center justify-center" style="width: 100%;">
<mat-card appearance="raised" class="tb-create-password-card flex-initial">
<mat-card-header>
<mat-card-title class="layout-padding">
<span translate class="mat-headline-5">login.create-password</span>
</mat-card-title>
<div class="tb-password-content mat-app-background tb-dark flex flex-row items-center justify-center" style="width: 100%;">
<mat-card appearance="raised" class="tb-password-card flex-initial">
<mat-card-header class="px-6 pt-6">
<mat-card-title class="text-xl font-normal" translate>login.create-password</mat-card-title>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
<div class="h-3 px-4"><div tb-toast></div></div>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<mat-card-content>
<span style="height: 4px;" *ngIf="!isLoading"></span>
<mat-card-content class="!p-6">
<form [formGroup]="createPassword" (ngSubmit)="onCreatePassword()">
<fieldset [disabled]="isLoading$ | async">
<div tb-toast class="layout-padding flex flex-col">
<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
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="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)">
{{ 'login.create-password' | translate }}
</button>
<button mat-button type="button" [disabled]="(isLoading$ | async)"
routerLink="/login">
{{ 'action.cancel' | translate }}
</button>
</div>
</div>
</fieldset>
<mat-form-field class="mat-block tb-autofilled" appearance="outline">
<mat-label translate>common.password</mat-label>
<input matInput
type="password"
autofocus
cdkOverlayOrigin
autocomplete="new-password"
#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-autofilled" style="margin-bottom: 18px" appearance="outline">
<mat-label translate>login.password-again</mat-label>
<input matInput type="password" formControlName="newPassword2" autocomplete="new-password"/>
<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-flat-button color="accent" type="submit" [disabled]="isLoading">
{{ 'login.create-password' | translate }}
</button>
<button mat-button type="button" [disabled]="isLoading" routerLink="/login">
{{ 'action.cancel' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>

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

@ -16,7 +16,6 @@
import { Component } from '@angular/core';
import { AuthService } from '@core/auth/auth.service';
import { PageComponent } from '@shared/components/page.component';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { UserPasswordPolicy } from '@shared/models/settings.models';
@ -25,19 +24,20 @@ import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/mode
@Component({
selector: 'tb-create-password',
templateUrl: './create-password.component.html',
styleUrls: ['./create-password.component.scss']
styleUrls: ['./password.component.scss']
})
export class CreatePasswordComponent extends PageComponent {
export class CreatePasswordComponent {
passwordPolicy: UserPasswordPolicy;
createPassword: FormGroup;
isLoading = false;
private activateToken: string;
constructor(private route: ActivatedRoute,
private authService: AuthService,
private fb: FormBuilder) {
super();
this.activateToken = this.route.snapshot.queryParams['activateToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
@ -60,9 +60,11 @@ export class CreatePasswordComponent extends PageComponent {
if (this.createPassword.invalid) {
this.createPassword.markAllAsTouched();
} else {
this.authService.activate(
this.activateToken,
this.createPassword.get('newPassword').value, true).subscribe();
this.isLoading = true
this.authService.activate(this.activateToken, this.createPassword.get('newPassword').value, true)
.subscribe({
error: () => {this.isLoading = false;}
});
}
}
}

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

@ -41,7 +41,7 @@
</button>
}
@if (config) {
<button type="button" mat-raised-button color="accent" class="navigation w-full" (click)="cancelLogin()">
<button type="button" mat-flat-button color="accent" class="navigation w-full" (click)="cancelLogin()">
{{ 'login.login' | translate }}
</button>
}
@ -66,7 +66,7 @@
<p class="mat-body qr-code-description mb-4" translate>login.scan-qr-code</p>
<canvas class="flex-1" #canvas [style.display]="totpAuthURL ? 'block' : 'none'"></canvas>
<p class="mat-body qr-code-description" translate>login.enter-key-manually</p>
<div class="flex flex-row items-center mb-8 w-full overflow-hidden">
<div class="mb-8 flex w-full flex-row items-center overflow-hidden">
<span tbTruncateWithTooltip class="w-full">{{ totpAuthURLSecret }}</span>
<tb-copy-button
class="attribute-copy"
@ -79,7 +79,7 @@
>
</tb-copy-button>
</div>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<div class="flex w-full flex-col items-center justify-start gap-2">
<button type="button" mat-stroked-button class="navigation w-full" (click)="appState.set(ProvidersState.ENTER_CODE)">
{{ 'login.continue' | translate }}
</button>
@ -126,7 +126,7 @@
</tb-phone-input>
</div>
</form>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<div class="flex w-full flex-col items-center justify-start gap-2">
<button type="button" mat-stroked-button [disabled]="(isLoading$ | async) || smsConfigForm.invalid || !smsConfigForm.dirty" class="navigation w-full" (click)="sendSmsCode()">
{{ 'login.send-code' | translate }}
</button>
@ -162,9 +162,10 @@
<div tb-toast class="flex flex-col items-center justify-start">
<form [formGroup]="emailConfigForm" class="mb-8 w-full">
<p class="mat-body step-description input" translate>login.email-description</p>
<mat-form-field class="mat-block input-container flex-1">
<mat-form-field class="mat-block input-container tb-autofilled flex-1" appearance="outline">
<input matInput formControlName="email"
type="email" required
autocomplete="email"
placeholder="{{ 'login.email-label' | translate }}" />
<mat-error *ngIf="emailConfigForm.get('email').hasError('required')">
{{ 'login.email-required' | translate }}
@ -174,7 +175,7 @@
</mat-error>
</mat-form-field>
</form>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<div class="flex w-full flex-col items-center justify-start gap-2">
<button type="button" mat-stroked-button [disabled]="(isLoading$ | async) || emailConfigForm.invalid" class="navigation w-full" (click)="sendEmailCode()">
{{ 'login.send-code' | translate }}
</button>
@ -223,7 +224,7 @@
</button>
</div>
<p class="mat-body-2 description" translate>login.backup-code-warn</p>
<button type="button" mat-raised-button color="accent" class="navigation w-full" (click)="backupCodeState.set(BackupCodeState.SUCCESS)">
<button type="button" mat-flat-button color="accent" class="navigation w-full" (click)="backupCodeState.set(BackupCodeState.SUCCESS)">
{{ 'login.continue' | translate }}
</button>
</div>
@ -259,11 +260,11 @@
}
</p>
<form [formGroup]="configForm" class="flex flex-col items-center justify-start">
<mat-form-field class="mat-block w-full">
<mat-form-field class="mat-block tb-autofilled w-full" appearance="outline">
<input matInput formControlName="verificationCode"
maxlength="6" type="text" required
inputmode="numeric" pattern="[0-9]*"
autocomplete="off"
autocomplete="one-time-code"
placeholder="{{ 'login.verification-code' | translate }}">
@if (configForm.get('verificationCode').getError('required') ||
configForm.get('verificationCode').getError('minlength') ||
@ -278,7 +279,7 @@
<mat-error>{{ 'login.verification-code-many-request' | translate }}</mat-error>
}
</mat-form-field>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<div class="flex w-full flex-col items-center justify-start gap-2">
<button type="button" mat-flat-button color="accent" [disabled]="(isLoading$ | async) || configForm.invalid || !configForm.dirty" class="navigation w-full" (click)="saveConfig(providerType)">
{{ 'login.confirm' | translate }}
</button>
@ -300,8 +301,8 @@
<mat-card-content>
<p class="mat-body mb-16" translate>{{ twoFactorAuthProvidersSuccessCardTranslate.get(providerType).description | translate }}</p>
<div class="flex flex-col items-center justify-start">
<div class="flex flex-col items-center justify-start gap-2 w-full">
<button type="button" mat-raised-button color="accent" class="navigation w-full" (click)="cancelLogin()">
<div class="flex w-full flex-col items-center justify-start gap-2">
<button type="button" mat-flat-button color="accent" class="navigation w-full" (click)="cancelLogin()">
{{ 'login.login' | translate }}
</button>
@if (isAnyProviderAvailable) {

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

@ -23,6 +23,7 @@
.tb-two-factor-auth-login-content {
background-color: #eee;
--mdc-elevated-card-container-elevation: none;
.progress-bar {
z-index: 10;

7
ui-ngx/src/app/modules/login/pages/login/link-expired.component.html

@ -23,16 +23,11 @@
<span class="mat-headline-5">{{ title | translate }}</span>
</mat-card-title>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<mat-card-content style="padding: 24px 16px">
<div>{{ message | translate }}</div>
</mat-card-content>
<mat-card-actions class="flex flex-row justify-center">
<button mat-raised-button color="accent"
[disabled]="(isLoading$ | async)"
(click)="navigateToLoginPage()">
<button mat-flat-button color="accent" routerLink="/login">
{{ 'login.login' | translate }}
</button>
</mat-card-actions>

1
ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss

@ -20,6 +20,7 @@
flex: 1 1 0;
.tb-expired-link-content {
background-color: #eee;
--mdc-elevated-card-container-elevation: none;
.tb-expired-link-card {
letter-spacing: 0.15px;
line-height: 24px;

16
ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts

@ -15,33 +15,23 @@
///
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'tb-link-expired',
templateUrl: './link-expired.component.html',
styleUrls: ['./link-expired.component.scss']
})
export class LinkExpiredComponent extends PageComponent {
export class LinkExpiredComponent {
isPasswordLinkExpired: boolean;
title: string;
message: string;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
private router: Router) {
super(store);
constructor(private route: ActivatedRoute) {
this.isPasswordLinkExpired = this.route.snapshot.data.passwordLinkExpired;
this.title = this.isPasswordLinkExpired ? 'login.reset-password-link-expired' : 'login.activation-link-expired';
this.message = this.isPasswordLinkExpired ? 'login.reset-password-link-expired-message' :
'login.activation-link-expired-message';
}
navigateToLoginPage() {
this.router.navigateByUrl('login');
}
}

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

@ -17,71 +17,67 @@
-->
<div class="tb-login-content mat-app-background tb-dark flex flex-1 items-center justify-center">
<mat-card appearance="raised" style="max-height: 80vh; overflow-y: auto;">
<mat-card-content>
<form class="tb-login-form" [formGroup]="loginFormGroup" (ngSubmit)="login()">
<fieldset class="flex flex-col">
<div class="flex flex-col items-center justify-start" style="padding: 15px 0;">
<tb-logo link="https://thingsboard.io" target="_blank" class="login-logo"></tb-logo>
</div>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<div tb-toast class="layout-padding flex flex-col">
<span style="height: 50px;"></span>
@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>
<mat-card-content class="!p-6">
<form class="tb-login-form flex flex-col" [formGroup]="loginFormGroup" (ngSubmit)="login()">
<div class="flex flex-col items-center justify-start" style="padding: 16px 0;">
<tb-logo link="https://thingsboard.io" target="_blank" class="login-logo"></tb-logo>
</div>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!isLoading"></span>
<span tb-toast style="height: 20px;"></span>
<div class="flex flex-col">
@if(oauth2Clients?.length) {
<div class="oauth-container tb-default flex flex-col gap-4">
@if(oauth2Clients.length !== 1) {
<div class="title">{{ 'login.sign-in-with' | translate: {name: ''} }}</div>
}
<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"
[href]=getOAuth2Uri(oauth2Client)>
<tb-icon matButtonIcon class="tb-mat-20">{{ oauth2Client.icon }}</tb-icon>
@if(oauth2Clients.length === 1) {
{{ 'login.sign-in-with' | translate: {name: oauth2Clients[0].name} }}
} @else {
{{ oauth2Client.name }}
}
</div>
</a>
}
<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>
}
<mat-form-field class="tb-appearance-transparent">
<mat-label translate>login.username</mat-label>
<input id="username-input" matInput type="email" autofocus formControlName="username"/>
<mat-icon matPrefix>email</mat-icon>
<mat-error *ngIf="loginFormGroup.get('username').invalid">
{{ 'login.invalid-email-format' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="tb-appearance-transparent">
<mat-label translate>common.password</mat-label>
<input id="password-input" matInput type="password" formControlName="password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-icon matPrefix>lock</mat-icon>
</mat-form-field>
<div class="forgot-password flex items-center justify-end">
<button class="tb-reset-password" mat-button type="button" routerLink="/login/resetPasswordRequest">
{{ (passwordViolation ? 'login.reset-password' : 'login.forgot-password') | translate }}
</button>
<div class="container-divider">
<mat-divider class="tb-dark flex-1"></mat-divider>
<div class="text">{{ "login.or" | translate | uppercase }}</div>
<mat-divider class="tb-dark flex-1"></mat-divider>
</div>
</div>
<div class="tb-action-button flex flex-col">
<button mat-raised-button color="accent" [disabled]="(isLoading$ | async)"
type="submit">{{ 'login.login' | translate }}</button>
} @else {
<div class="oauth-container flex flex-col">
<div class="title !mb-4" translate>login.sign-in-to-your-account</div>
</div>
}
<mat-form-field class="tb-autofilled" appearance="outline" hideRequiredMarker>
<mat-label translate>login.username</mat-label>
<input id="username-input" matInput type="email" autofocus formControlName="username" autocomplete="email"/>
<mat-icon matPrefix>email</mat-icon>
<mat-error *ngIf="loginFormGroup.get('username').invalid">
{{ 'login.invalid-email-format' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="tb-autofilled" appearance="outline">
<mat-label translate>common.password</mat-label>
<input id="password-input" matInput type="password" formControlName="password" autocomplete="current-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-icon matPrefix>lock</mat-icon>
</mat-form-field>
<button mat-flat-button color="accent" [disabled]="isLoading"
class="tb-action-button" type="submit">{{ 'login.sign-in' | translate }}</button>
<div class="forgot-password flex items-center justify-end gap-5">
<button class="tb-reset-password" mat-button type="button" routerLink="/login/resetPasswordRequest">
{{ (passwordViolation ? 'login.reset-password' : 'login.forgot-password') | translate }}
</button>
</div>
</fieldset>
</div>
</form>
</mat-card-content>
</mat-card>

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

@ -23,48 +23,49 @@
margin-top: 36px;
margin-bottom: 76px;
background-color: #eee;
--mdc-elevated-card-container-elevation: none;
.tb-login-form {
@media #{$mat-gt-xs} {
width: 550px !important;
width: 480px !important;
}
.forgot-password {
padding: 0 0.5em 1em;
.tb-reset-password {
padding: 0 6px;
}
--mat-text-button-horizontal-padding: 0;
--mdc-text-button-container-height: 20px;
}
.tb-action-button{
padding: 20px 0 16px;
margin: 20px 0 16px;
}
}
.oauth-container{
padding: 0;
.container-divider {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
.container-divider {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 16px;
.line {
flex: 1;
}
.mat-divider-horizontal{
--mat-divider-color: var(--mdc-outlined-text-field-outline-color);
}
.mat-divider-horizontal{
position: relative;
}
.text {
padding-right: 8px;
padding-left: 8px;
font-size: 16px;
line-height: 24px;
}
}
.text {
padding-right: 8px;
padding-left: 8px;
font-size: 16px;
line-height: 24px;
letter-spacing: 0.15px;
}
.oauth-container{
.title {
font-size: 20px;
line-height: 24px;
letter-spacing: .1px;
text-align: center;
margin-bottom: 4px;
}
.material-icons{
@ -74,33 +75,32 @@
a.login-with-button {
color: rgba(black, 0.87);
background-color: map-get($tb-dark-theme-background, raised-button);
background-color: #ffffff;
}
.login-button-container {
a.login-with-button {
--mdc-outlined-button-container-shape: 8px;
max-width: 123px;
max-width: 170px;
min-width: 60px;
flex-grow: 1;
flex-basis: 120px;
flex-basis: 130px;
.tb-mat-20 {
margin: 0;
vertical-align: text-top;
margin-right: 12px;
}
}
&:has(> :nth-child(2):last-child) {
&:has(> :nth-child(1):last-child) {
a.login-with-button {
max-width: 180px;
flex-basis: 180px;
max-width: 100%;
}
}
&:has(> :nth-child(3):last-child) {
&:has(> :nth-child(2):last-child),
&:has(> :nth-child(4):last-child) {
a.login-with-button {
max-width: 180px;
max-width: 100%;
flex-basis: 180px;
}
}
}

25
ui-ngx/src/app/modules/login/pages/login/login.component.ts

@ -16,9 +16,6 @@
import { Component, OnInit } 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, Validators } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { Constants } from '@shared/models/constants';
@ -31,9 +28,10 @@ import { validateEmail } from '@app/core/utils';
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent extends PageComponent implements OnInit {
export class LoginComponent implements OnInit {
passwordViolation = false;
isLoading = false;
loginFormGroup = this.fb.group({
username: ['', [Validators.required, validateEmail]],
@ -41,11 +39,9 @@ export class LoginComponent extends PageComponent implements OnInit {
});
oauth2Clients: Array<OAuth2ClientLoginInfo> = null;
constructor(protected store: Store<AppState>,
private authService: AuthService,
constructor(private authService: AuthService,
public fb: UntypedFormBuilder,
private router: Router) {
super(store);
}
ngOnInit() {
@ -54,9 +50,11 @@ export class LoginComponent extends PageComponent implements OnInit {
login(): void {
if (this.loginFormGroup.valid) {
this.authService.login(this.loginFormGroup.value).subscribe(
() => {},
(error: HttpErrorResponse) => {
this.isLoading = true;
this.authService.login(this.loginFormGroup.value).subscribe({
next: () => {},
error: (error: HttpErrorResponse) => {
this.isLoading = false;
if (error && error.error && error.error.errorCode) {
if (error.error.errorCode === Constants.serverErrorCode.credentialsExpired) {
this.router.navigateByUrl(`login/resetExpiredPassword?resetToken=${error.error.resetToken}`);
@ -65,12 +63,9 @@ export class LoginComponent extends PageComponent implements OnInit {
}
}
}
);
} else {
Object.keys(this.loginFormGroup.controls).forEach(field => {
const control = this.loginFormGroup.get(field);
control.markAsTouched({onlySelf: true});
});
} else {
this.loginFormGroup.markAllAsTouched();
}
}

5
ui-ngx/src/app/modules/login/pages/login/create-password.component.scss → ui-ngx/src/app/modules/login/pages/login/password.component.scss

@ -18,9 +18,10 @@
:host {
display: flex;
flex: 1 1 0;
.tb-create-password-content {
.tb-password-content {
background-color: #eee;
.tb-create-password-card {
--mdc-elevated-card-container-elevation: none;
.tb-password-card {
@media #{$mat-gt-xs} {
width: 450px !important;
}

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

@ -15,41 +15,35 @@
limitations under the License.
-->
<div class="tb-request-password-reset-content mat-app-background tb-dark flex flex-row items-center justify-center"
<div class="tb-password-content mat-app-background tb-dark flex flex-row items-center justify-center"
style="width: 100%;">
<mat-card appearance="raised" class="tb-request-password-reset-card flex flex-initial">
<mat-card-header>
<mat-card-title class="layout-padding">
<span translate class="mat-headline-5">login.request-password-reset</span>
</mat-card-title>
<mat-card appearance="raised" class="tb-password-card flex flex-initial">
<mat-card-header class="px-6 pt-6">
<mat-card-title class="text-xl font-normal" translate>login.request-password-reset</mat-card-title>
</mat-card-header>
<div class="h-3 px-4"><div tb-toast></div></div>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<mat-card-content>
<mat-card-content class="!p-6">
<form [formGroup]="requestPasswordRequest" (ngSubmit)="sendResetPasswordLink()">
<fieldset [disabled]="isLoading$ | async">
<div tb-toast class="layout-padding flex flex-col">
<span style="height: 50px;"></span>
<mat-form-field class="mat-block tb-appearance-transparent" hideRequiredMarker>
<mat-label translate>login.email</mat-label>
<input matInput type="email" autofocus formControlName="email"/>
<mat-icon class="material-icons" matPrefix>email</mat-icon>
<mat-error *ngIf="requestPasswordRequest.get('email').invalid">
{{ 'user.invalid-email-format' | 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) || this.clicked">
{{ 'login.request-password-reset' | translate }}
</button>
<button mat-button type="button" [disabled]="(isLoading$ | async)"
routerLink="/login">
{{ 'action.cancel' | translate }}
</button>
</div>
</div>
</fieldset>
<mat-form-field class="mat-block tb-autofilled" style="margin-bottom: 18px" appearance="outline" hideRequiredMarker>
<mat-label translate>login.email</mat-label>
<input matInput type="email" autofocus formControlName="email" autocomplete="email"/>
<mat-icon class="material-icons" matPrefix>email</mat-icon>
<mat-error *ngIf="requestPasswordRequest.get('email').invalid">
{{ 'user.invalid-email-format' | 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-flat-button color="accent" type="submit" [disabled]="(isLoading$ | async) || this.clicked">
{{ 'login.request-password-reset' | translate }}
</button>
<button mat-button type="button" [disabled]="(isLoading$ | async)"
routerLink="/login">
{{ 'action.cancel' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>

29
ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss

@ -1,29 +0,0 @@
/**
* 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 '../../../../../scss/constants';
:host {
display: flex;
flex: 1 1 0;
.tb-request-password-reset-content {
background-color: #eee;
.tb-request-password-reset-card {
@media #{$mat-gt-xs} {
width: 450px !important;
}
}
}
}

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

@ -27,9 +27,9 @@ import { validateEmail } from '@app/core/utils';
@Component({
selector: 'tb-reset-password-request',
templateUrl: './reset-password-request.component.html',
styleUrls: ['./reset-password-request.component.scss']
styleUrls: ['./password.component.scss']
})
export class ResetPasswordRequestComponent extends PageComponent implements OnInit {
export class ResetPasswordRequestComponent extends PageComponent {
clicked: boolean = false;
@ -44,9 +44,6 @@ export class ResetPasswordRequestComponent extends PageComponent implements OnIn
super(store);
}
ngOnInit() {
}
disableInputs() {
this.requestPasswordRequest.disable();
this.clicked = true;

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

@ -15,60 +15,54 @@
limitations under the License.
-->
<div class="tb-reset-password-content mat-app-background tb-dark flex flex-row items-center justify-center" style="width: 100%;">
<mat-card appearance="raised" class="tb-reset-password-card flex-initial">
<mat-card-header>
<mat-card-title class="layout-padding tb-card-title mb-4">
<span translate class="mat-headline-5 tb-card-title">login.password-reset</span>
</mat-card-title>
<mat-card-subtitle *ngIf="isExpiredPassword" class="layout-padding tb-card-title">
<div class="tb-card-title whitespace-pre-line">{{ 'login.expired-password-reset-message' | translate }}</div>
<div class="tb-password-content mat-app-background tb-dark flex flex-row items-center justify-center" style="width: 100%;">
<mat-card appearance="raised" class="tb-password-card flex-initial">
<mat-card-header class="px-6 pt-6">
<mat-card-title class="text-xl font-normal" translate>login.password-reset</mat-card-title>
<mat-card-subtitle *ngIf="isExpiredPassword" class="mt-4 whitespace-pre-line text-sm font-normal" translate>
login.expired-password-reset-message
</mat-card-subtitle>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
<div class="h-3 px-4"><div tb-toast></div></div>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<mat-card-content>
<span style="height: 4px;" *ngIf="!isLoading"></span>
<mat-card-content class="!p-6">
<form [formGroup]="resetPassword" (ngSubmit)="onResetPassword()">
<fieldset [disabled]="isLoading$ | async">
<div tb-toast class="layout-padding flex flex-col">
<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
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)">
{{ 'login.reset-password' | translate }}
</button>
<button mat-button type="button" [disabled]="(isLoading$ | async)"
routerLink="/login">
{{ 'action.cancel' | translate }}
</button>
</div>
</div>
</fieldset>
<mat-form-field class="mat-block tb-autofilled" appearance="outline">
<mat-label translate>login.new-password</mat-label>
<input matInput
type="password"
autofocus
cdkOverlayOrigin
autocomplete="new-password"
#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-autofilled" appearance="outline" style="margin-bottom: 18px">
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" formControlName="newPassword2" autocomplete="new-password"/>
<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-flat-button color="accent" type="submit" [disabled]="isLoading">
{{ 'login.reset-password' | translate }}
</button>
<button mat-button type="button" [disabled]="isLoading" routerLink="/login">
{{ 'action.cancel' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>

34
ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss

@ -1,34 +0,0 @@
/**
* 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 '../../../../../scss/constants';
:host {
display: flex;
flex: 1 1 0;
.tb-reset-password-content {
background-color: #eee;
.tb-reset-password-card {
@media #{$mat-gt-sm} {
width: 450px !important;
}
}
.tb-card-title{
padding-top: 0;
padding-bottom: 0;
}
}
}

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

@ -16,7 +16,6 @@
import { Component } from '@angular/core';
import { AuthService } from '@core/auth/auth.service';
import { PageComponent } from '@shared/components/page.component';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { UserPasswordPolicy } from '@shared/models/settings.models';
@ -25,11 +24,12 @@ import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/mode
@Component({
selector: 'tb-reset-password',
templateUrl: './reset-password.component.html',
styleUrls: ['./reset-password.component.scss']
styleUrls: ['./password.component.scss']
})
export class ResetPasswordComponent extends PageComponent {
export class ResetPasswordComponent {
isExpiredPassword: boolean;
isLoading = false;
resetPassword: FormGroup;
passwordPolicy: UserPasswordPolicy;
@ -40,7 +40,6 @@ export class ResetPasswordComponent extends PageComponent {
private router: Router,
private authService: AuthService,
private fb: FormBuilder) {
super();
this.resetToken = this.route.snapshot.queryParams['resetToken'] || '';
this.passwordPolicy = this.route.snapshot.data['passwordPolicy'];
@ -62,13 +61,13 @@ export class ResetPasswordComponent extends PageComponent {
onResetPassword() {
if (this.resetPassword.invalid) {
this.resetPassword.markAllAsTouched();
this.resetPassword.markAllAsTouched();
} else {
this.authService.resetPassword(
this.resetToken,
this.resetPassword.get('newPassword').value).subscribe(
() => this.router.navigateByUrl('login')
);
this.isLoading = true;
this.authService.resetPassword(this.resetToken, this.resetPassword.get('newPassword').value).subscribe({
next: () => this.router.navigateByUrl('login'),
error: () => {this.isLoading = false;}
});
}
}
}

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

@ -41,11 +41,11 @@
<div tb-toast class="flex flex-col">
<p class="mat-body">{{ providerDescription }}</p>
<div class="code-block flex flex-row gap-2">
<mat-form-field class="mat-block tb-appearance-transparent flex-1">
<mat-form-field class="mat-block tb-autofilled flex-1" appearance="outline">
<input matInput formControlName="verificationCode"
required [maxlength]="maxLengthInput" type="text"
[attr.inputmode]="inputMode" [pattern]="pattern"
autocomplete="off"
autocomplete="one-time-code"
placeholder="{{ providersData.get(selectedProvider).placeholder | translate }}"/>
<mat-error *ngIf="verificationForm.get('verificationCode').getError('required') ||
verificationForm.get('verificationCode').getError('minlength') ||
@ -62,7 +62,7 @@
</mat-form-field>
</div>
<span style="height: 50px;"></span>
<button mat-raised-button
<button mat-flat-button
color="accent"
[disabled]="(isLoading$ | async) || verificationForm.invalid"
type="submit">

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

@ -21,6 +21,7 @@
.tb-two-factor-auth-login-content {
background-color: #eee;
--mdc-elevated-card-container-elevation: none;
.tb-two-factor-auth-login-card {
padding: 48px 48px 48px 16px;

47
ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts

@ -0,0 +1,47 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { OverlayModule } from '@angular/cdk/overlay';
import { NgModule } from '@angular/core';
import { DEFAULT_DIALOG_CONFIG, DialogConfig, DialogModule } from '@angular/cdk/dialog';
import { MatDialogModule } from '@angular/material/dialog';
import { DynamicDialog, DynamicMatDialog } from './dynamic-dialog';
import { DynamicOverlay } from './dynamic-overlay';
import { DynamicOverlayContainer } from './dynamic-overlay-container';
export const DYNAMIC_MAT_DIALOG_PROVIDERS = [
DynamicOverlayContainer,
DynamicOverlay,
DynamicDialog,
DynamicMatDialog,
{
provide: DEFAULT_DIALOG_CONFIG,
useValue: {
...new DialogConfig()
}
}
];
@NgModule( {
imports: [
OverlayModule,
DialogModule,
MatDialogModule
],
providers: DYNAMIC_MAT_DIALOG_PROVIDERS
} )
export class DynamicMatDialogModule {
}

85
ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.ts

@ -0,0 +1,85 @@
///
/// 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 { Location } from "@angular/common";
import { Inject, Injectable, Injector, Optional, SkipSelf, TemplateRef } from '@angular/core';
import {
MAT_DIALOG_DEFAULT_OPTIONS,
MAT_DIALOG_SCROLL_STRATEGY,
MatDialog,
MatDialogConfig, MatDialogRef
} from '@angular/material/dialog';
import { DynamicOverlay } from "./dynamic-overlay";
import { DynamicOverlayContainer } from '@shared/components/dialog/dynamic/dynamic-overlay-container';
import { ComponentType, ScrollStrategy } from '@angular/cdk/overlay';
import { DEFAULT_DIALOG_CONFIG, Dialog, DialogConfig } from '@angular/cdk/dialog';
export interface DynamicMatDialogConfig<D> extends MatDialogConfig<D> {
containerElement?: HTMLElement;
}
@Injectable()
export class DynamicMatDialog extends MatDialog {
private _customOverlay: DynamicOverlay;
constructor( _overlay: DynamicOverlay,
_injector: Injector,
@Optional() location: Location,
@Inject( MAT_DIALOG_DEFAULT_OPTIONS ) _defaultOptions: MatDialogConfig,
@Inject( MAT_DIALOG_SCROLL_STRATEGY ) _scrollStrategy: ScrollStrategy,
@Optional() @SkipSelf() _parentDialog:DynamicMatDialog,
_overlayContainer: DynamicOverlayContainer) {
super( _overlay, _injector, location, _defaultOptions, _scrollStrategy, _parentDialog, _overlayContainer );
this._dialog = _injector.get(DynamicDialog);
this._customOverlay = _overlay;
}
public open<T, D = any, R = any>(component: ComponentType<T> | TemplateRef<T>, config?: DynamicMatDialogConfig<D>): MatDialogRef<T, R> {
if (config?.containerElement) {
config.containerElement.style.transform = 'translateZ(0)';
this._customOverlay.setContainerElement( config.containerElement );
}
const ref = super.open(component, config);
if (config?.containerElement) {
ref.afterClosed().subscribe(
{
next: () => {
this._customOverlay.setContainerElement(null);
},
error: () => {
this._customOverlay.setContainerElement(null);
}
}
);
}
return ref;
}
}
@Injectable()
export class DynamicDialog extends Dialog {
constructor( _overlay: DynamicOverlay,
_injector: Injector,
@Inject( DEFAULT_DIALOG_CONFIG ) _defaultOptions: DialogConfig,
@Inject( MAT_DIALOG_SCROLL_STRATEGY ) _scrollStrategy: ScrollStrategy,
@Optional() @SkipSelf() _parentDialog: DynamicDialog,
_overlayContainer: DynamicOverlayContainer) {
super( _overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, _scrollStrategy );
}
}

27
ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay-container.ts

@ -0,0 +1,27 @@
///
/// 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 { OverlayContainer } from "@angular/cdk/overlay";
import { Injectable } from "@angular/core";
@Injectable()
export class DynamicOverlayContainer extends OverlayContainer {
public setContainerElement( containerElement:HTMLElement ):void {
this._containerElement = containerElement;
}
}

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

Loading…
Cancel
Save