Browse Source

Merge branch 'master' of github.com:thingsboard/thingsboard into feature/tb-client-tests

pull/15264/head
Viacheslav Klimov 6 months ago
parent
commit
d2afdf36e3
Failed to extract signature
  1. 18
      application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
  2. 85
      application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java
  3. 712
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  4. 1029
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
  5. 1
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java
  6. 57
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java
  7. 238
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java
  8. 131
      application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java
  9. 186
      application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java
  10. 46
      application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java
  11. 23
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java
  12. 218
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java
  13. 392
      application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java
  14. 131
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java
  15. 16
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java
  16. 621
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java
  17. 34
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java
  18. 43
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java
  19. 83
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java
  20. 23
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java
  21. 149
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java
  22. 47
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java
  23. 302
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java
  24. 183
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java
  25. 9
      application/src/main/resources/thingsboard.yml
  26. 12
      application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
  27. 3
      application/src/test/resources/logback-test.xml
  28. 6
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  29. 2
      common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
  30. 62
      pom.xml
  31. 2
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java
  32. 2
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java
  33. 26
      ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html
  34. 128
      ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html
  35. 3
      ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss
  36. 141
      ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html
  37. 104
      ui-ngx/src/app/modules/home/components/device/device-credentials.component.html
  38. 109
      ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html
  39. 1
      ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss
  40. 2
      ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html
  41. 5
      ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts
  42. 90
      ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html
  43. 126
      ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html
  44. 4
      ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts
  45. 248
      ui-ngx/src/app/modules/home/components/profile/device-profile.component.html
  46. 138
      ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html
  47. 67
      ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html
  48. 4
      ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html
  49. 4
      ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts
  50. 38
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html
  51. 9
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts
  52. 234
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html
  53. 82
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html
  54. 2
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html
  55. 4
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts
  56. 105
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html
  57. 71
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html
  58. 54
      ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html
  59. 288
      ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html
  60. 4
      ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts
  61. 109
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html
  62. 6
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts
  63. 32
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html
  64. 94
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
  65. 2
      ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html
  66. 4
      ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts
  67. 84
      ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html
  68. 65
      ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html
  69. 1
      ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss
  70. 125
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html
  71. 2
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts
  72. 45
      ui-ngx/src/app/modules/home/pages/asset/asset.component.html
  73. 64
      ui-ngx/src/app/modules/home/pages/customer/customer.component.html
  74. 38
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html
  75. 40
      ui-ngx/src/app/modules/home/pages/device/device.component.html
  76. 90
      ui-ngx/src/app/modules/home/pages/edge/edge.component.html
  77. 43
      ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html
  78. 2
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts
  79. 150
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html
  80. 109
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html
  81. 39
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html
  82. 257
      ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html
  83. 17
      ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html
  84. 62
      ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.html
  85. 1167
      ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html
  86. 1
      ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss
  87. 278
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html
  88. 1
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss
  89. 79
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html
  90. 486
      ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html
  91. 55
      ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html
  92. 1
      ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss
  93. 195
      ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html
  94. 60
      ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html
  95. 18
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html
  96. 21
      ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html
  97. 38
      ui-ngx/src/app/shared/components/contact.component.html
  98. 2
      ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.html
  99. 5
      ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.ts
  100. 2
      ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html

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

@ -15,11 +15,14 @@
*/
package org.thingsboard.server.service.edge;
import jakarta.annotation.PreDestroy;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.edge.EdgeEventType;
@ -86,6 +89,8 @@ import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@Lazy
@Data
@ -96,14 +101,25 @@ public class EdgeContextComponent {
private final Map<EdgeEventType, EdgeProcessor> processorMap = new EnumMap<>(EdgeEventType.class);
private ScheduledExecutorService edgeEventProcessingExecutorService;
@Autowired
public EdgeContextComponent(List<EdgeProcessor> processors) {
public EdgeContextComponent(List<EdgeProcessor> processors,
@Value("${edges.scheduler_pool_size}") int schedulerPoolSize) {
processors.forEach(processor -> {
EdgeEventType eventType = processor.getEdgeEventType();
if (eventType != null) {
processorMap.put(eventType, processor);
}
});
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
}
@PreDestroy
private void destroy() {
if (edgeEventProcessingExecutorService != null && !edgeEventProcessingExecutorService.isShutdown()) {
edgeEventProcessingExecutorService.shutdownNow();
}
}
// services

85
application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java

@ -0,0 +1,85 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.utils.EdgeVersionUtils;
import java.util.ArrayList;
import java.util.List;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class DownlinkMessageMapper {
private final EdgeContextComponent ctx;
public List<DownlinkMsg> convertToDownlinkMsgsPack(EdgeSessionState state, List<EdgeEvent> edgeEvents) {
List<DownlinkMsg> result = new ArrayList<>();
List<EdgeEvent> filtered = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(edgeEvents);
for (EdgeEvent edgeEvent : filtered) {
log.trace("[{}][{}] converting edge event to downlink msg [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent);
DownlinkMsg downlinkMsg = null;
try {
switch (edgeEvent.getAction()) {
case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR,
ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, RPC_CALL,
ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> {
downlinkMsg = convertEntityEventToDownlink(state, edgeEvent);
if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) {
log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}",
state.getTenantId(), state.getEdgeId(), downlinkMsg.getDownlinkMsgId());
} else {
log.trace("[{}][{}] entity message processed [{}]", state.getTenantId(), state.getEdgeId(), downlinkMsg);
}
}
case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED ->
downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(state.getEdge(), edgeEvent);
default -> log.warn("[{}][{}] Unsupported action type [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent.getAction());
}
} catch (Exception e) {
log.trace("[{}][{}] Exception during converting edge event to downlink msg", state.getTenantId(), state.getEdgeId(), e);
}
if (downlinkMsg != null) {
result.add(downlinkMsg);
}
}
return result;
}
protected DownlinkMsg convertEntityEventToDownlink(EdgeSessionState state, EdgeEvent edgeEvent) {
log.trace("[{}] Executing convertEntityEventToDownlink, edgeEvent [{}], action [{}]", edgeEvent.getTenantId(), edgeEvent, edgeEvent.getAction());
if ((EdgeEventType.OAUTH2_CLIENT.equals(edgeEvent.getType()) || EdgeEventType.DOMAIN.equals(edgeEvent.getType())) &&
(EdgeVersionUtils.isEdgeVersionOlderThan(state.getEdgeVersion(), EdgeVersion.V_3_8_0))) {
return null;
}
return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, state.getEdgeVersion());
}
}

712
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -1,712 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME;
@Service
@Slf4j
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
private final ConcurrentMap<EdgeId, EdgeGrpcSession> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, EdgeGrpcSession> sessionsById = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Lock> sessionNewEventsLocks = new ConcurrentHashMap<>();
private final Map<EdgeId, Boolean> sessionNewEvents = new HashMap<>();
private final ConcurrentMap<EdgeId, ScheduledFuture<?>> sessionEdgeEventChecks = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Boolean> edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
private final List<EdgeGrpcSession> zombieSessions = new ArrayList<>();
@Value("${edges.rpc.port}")
private int rpcPort;
@Value("${edges.rpc.ssl.enabled}")
private boolean sslEnabled;
@Value("${edges.rpc.ssl.cert}")
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
private int clientMaxKeepAliveTimeSec;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
private int maxInboundMessageSize;
@Value("${edges.rpc.keep_alive_time_sec:10}")
private int keepAliveTimeSec;
@Value("${edges.rpc.keep_alive_timeout_sec:5}")
private int keepAliveTimeoutSec;
@Value("${edges.scheduler_pool_size}")
private int schedulerPoolSize;
@Value("${edges.send_scheduler_pool_size}")
private int sendSchedulerPoolSize;
@Value("${edges.max_high_priority_queue_size_per_session:10000}")
private int maxHighPriorityQueueSizePerSession;
@Autowired
@Lazy
private EdgeContextComponent ctx;
@Autowired
private TbClusterService clusterService;
@Autowired
private TbServiceInfoProvider serviceInfoProvider;
@Autowired
private TbTransactionalCache<EdgeId, String> edgeIdServiceIdCache;
@Autowired
private TopicService topicService;
@Autowired
private TbCoreQueueFactory tbCoreQueueFactory;
@Autowired
private Optional<KafkaAdmin> kafkaAdmin;
private Server server;
private ScheduledExecutorService edgeEventProcessingExecutorService;
private ScheduledExecutorService sendDownlinkExecutorService;
private ScheduledExecutorService executorService;
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
public void onStartUp() {
log.info("Initializing Edge RPC service!");
NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort)
.permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS)
.permitKeepAliveWithoutCalls(true)
.maxInboundMessageSize(maxInboundMessageSize)
.addService(this);
if (sslEnabled) {
try {
setupSsl(builder);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e);
}
}
server = builder.build();
log.info("Going to start Edge RPC server using port: {}", rpcPort);
try {
server.start();
} catch (IOException e) {
log.error("Failed to start Edge RPC server!", e);
throw new RuntimeException("Failed to start Edge RPC server!");
}
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS);
log.info("Edge RPC service initialized!");
}
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) throws Exception {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
}
@PreDestroy
public void destroy() {
if (server != null) {
server.shutdownNow();
}
for (Map.Entry<EdgeId, ScheduledFuture<?>> entry : sessionEdgeEventChecks.entrySet()) {
EdgeId edgeId = entry.getKey();
ScheduledFuture<?> sessionEdgeEventCheck = entry.getValue();
if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) {
sessionEdgeEventCheck.cancel(true);
sessionEdgeEventChecks.remove(edgeId);
}
}
if (edgeEventProcessingExecutorService != null) {
edgeEventProcessingExecutorService.shutdownNow();
}
if (sendDownlinkExecutorService != null) {
sendDownlinkExecutorService.shutdownNow();
}
if (executorService != null) {
executorService.shutdownNow();
}
}
@Override
public StreamObserver<RequestMsg> handleMsgs(StreamObserver<ResponseMsg> outputStream) {
EdgeGrpcSession session = createEdgeGrpcSession(outputStream);
return session.getInputStream();
}
private EdgeGrpcSession createEdgeGrpcSession(StreamObserver<ResponseMsg> outputStream) {
return kafkaAdmin.isPresent()
? new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin.get(), outputStream, this::onEdgeConnect, this::onEdgeDisconnect,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession)
: new PostgresEdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
}
@Override
public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) {
switch (msg.getMsgType()) {
case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> {
EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg;
log.trace("[{}] edgeEventMsg [{}]", tenantId, msg);
onEdgeHighPriorityEvent(edgeHighPriorityMsg);
}
case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> {
EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg;
log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg);
onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId());
}
case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> {
ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg;
log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg);
startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId());
}
case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> {
FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg;
log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg);
processSyncResponse(fromEdgeSyncResponse);
}
}
}
@Override
public void updateEdge(TenantId tenantId, Edge edge) {
if (edge == null) {
log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId);
return;
}
EdgeGrpcSession session = sessions.get(edge.getId());
if (session != null && session.isConnected()) {
log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
session.onConfigurationUpdate(edge);
} else {
log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
}
}
@Override
public void deleteEdge(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId);
destroySession(session);
session.cleanUp();
sessions.remove(edgeId);
sessionsById.remove(session.getSessionId());
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.remove(edgeId);
} finally {
newEventLock.unlock();
}
cancelScheduleEdgeEventsCheck(edgeId);
}
}
private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId());
updateSessionEventsFlag(tenantId, edgeId);
}
}
private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) {
TenantId tenantId = msg.getTenantId();
EdgeEvent edgeEvent = msg.getEdgeEvent();
EdgeId edgeId = edgeEvent.getEdgeId();
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId);
session.addEventToHighPriorityQueue(edgeEvent);
updateSessionEventsFlag(tenantId, edgeId);
}
}
private void updateSessionEventsFlag(TenantId tenantId, EdgeId edgeId) {
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
if (Boolean.FALSE.equals(sessionNewEvents.get(edgeId))) {
log.trace("[{}] set session new events flag to true [{}]", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, true);
}
} finally {
newEventLock.unlock();
}
}
private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSession edgeGrpcSession) {
Edge edge = edgeGrpcSession.getEdge();
TenantId tenantId = edge.getTenantId();
log.info("[{}][{}] edge [{}] connected successfully.", tenantId, edgeGrpcSession.getSessionId(), edgeId);
if (sessions.containsKey(edgeId)) {
EdgeGrpcSession existing = sessions.get(edgeId);
if (existing != null) {
log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, edgeGrpcSession.getSessionId(), existing.getSessionId(), edgeId);
destroySession(existing);
sessionsById.remove(existing.getSessionId());
}
}
sessions.put(edgeId, edgeGrpcSession);
sessionsById.put(edgeGrpcSession.getSessionId(), edgeGrpcSession);
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.put(edgeId, true);
} finally {
newEventLock.unlock();
}
save(tenantId, edgeId, ACTIVITY_STATE, true);
long lastConnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs);
edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId());
pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT);
cancelScheduleEdgeEventsCheck(edgeId);
edgeEventsMigrationProcessed.putIfAbsent(edgeId, Boolean.FALSE);
scheduleEdgeEventsCheck(edgeGrpcSession);
}
private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null) {
if (session.isSyncInProgress()) {
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId);
} else {
boolean success = false;
if (session.isConnected()) {
session.startSyncProcess(true);
success = true;
}
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId);
}
}
}
@Override
public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer<FromEdgeSyncResponse> responseConsumer) {
ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId());
UUID requestId = request.getId();
EdgeGrpcSession session = sessions.get(request.getEdgeId());
if (session != null && session.isSyncInProgress()) {
responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment"));
} else {
log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId());
localSyncEdgeRequests.put(requestId, responseConsumer);
clusterService.pushEdgeSyncRequestToEdge(request);
scheduleSyncRequestTimeout(request, requestId);
}
}
private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) {
log.trace("[{}] scheduling sync edge request", requestId);
executorService.schedule(() -> {
log.trace("[{}] checking if sync edge request is not processed...", requestId);
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
log.trace("[{}] timeout for processing sync edge request.", requestId);
consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected"));
}
}, 20, TimeUnit.SECONDS);
}
private void processSyncResponse(FromEdgeSyncResponse response) {
log.trace("[{}] Received response from sync service: [{}]", response.getId(), response);
UUID requestId = response.getId();
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
consumer.accept(response);
} else {
log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response);
}
}
private void scheduleEdgeEventsCheck(EdgeGrpcSession session) {
EdgeId edgeId = session.getEdge().getId();
TenantId tenantId = session.getEdge().getTenantId();
cancelScheduleEdgeEventsCheck(edgeId);
if (sessions.containsKey(edgeId)) {
ScheduledFuture<?> edgeEventCheckTask = edgeEventProcessingExecutorService.schedule(() -> {
try {
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
if (Boolean.TRUE.equals(sessionNewEvents.get(edgeId))) {
log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, false);
session.processHighPriorityEvents();
processEdgeEventMigrationIfNeeded(session, edgeId);
if (Boolean.TRUE.equals(edgeEventsMigrationProcessed.get(edgeId))) {
Futures.addCallback(session.processEdgeEvents(), new FutureCallback<>() {
@Override
public void onSuccess(Boolean newEventsAdded) {
if (Boolean.TRUE.equals(newEventsAdded)) {
log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, true);
}
scheduleEdgeEventsCheck(session);
}
@Override
public void onFailure(Throwable t) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), t);
scheduleEdgeEventsCheck(session);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
scheduleEdgeEventsCheck(session);
}
} else {
scheduleEdgeEventsCheck(session);
}
} finally {
newEventLock.unlock();
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), e);
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
sessionEdgeEventChecks.put(edgeId, edgeEventCheckTask);
log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId());
} else {
log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]",
tenantId, edgeId.getId());
}
}
private void processEdgeEventMigrationIfNeeded(EdgeGrpcSession session, EdgeId edgeId) throws Exception {
boolean isMigrationProcessed = edgeEventsMigrationProcessed.getOrDefault(edgeId, Boolean.FALSE);
if (!isMigrationProcessed) {
log.info("Starting edge event migration for edge [{}]", edgeId.getId());
Boolean eventsExist = session.migrateEdgeEvents().get();
if (Boolean.TRUE.equals(eventsExist)) {
log.info("Migration still in progress for edge [{}]", edgeId.getId());
sessionNewEvents.put(edgeId, true);
scheduleEdgeEventsCheck(session);
} else if (Boolean.FALSE.equals(eventsExist)) {
log.info("Migration completed for edge [{}]", edgeId.getId());
edgeEventsMigrationProcessed.put(edgeId, true);
}
}
}
private void cancelScheduleEdgeEventsCheck(EdgeId edgeId) {
log.trace("[{}] cancelling edge event check for edge", edgeId);
if (sessionEdgeEventChecks.containsKey(edgeId)) {
ScheduledFuture<?> sessionEdgeEventCheck = sessionEdgeEventChecks.get(edgeId);
if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) {
sessionEdgeEventCheck.cancel(true);
sessionEdgeEventChecks.remove(edgeId);
}
}
}
private void onEdgeDisconnect(Edge edge, UUID sessionId) {
EdgeId edgeId = edge.getId();
log.info("[{}][{}] edge disconnected!", edgeId, sessionId);
EdgeGrpcSession current = sessions.get(edgeId);
if (current != null && current.getSessionId().equals(sessionId)) {
EdgeGrpcSession toRemove = sessions.remove(edgeId);
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.remove(edgeId);
} finally {
newEventLock.unlock();
}
destroySession(toRemove);
sessionsById.remove(sessionId);
TenantId tenantId = toRemove.getEdge().getTenantId();
save(tenantId, edgeId, ACTIVITY_STATE, false);
long lastDisconnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs);
pushRuleEngineMessage(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT);
cancelScheduleEdgeEventsCheck(edgeId);
} else {
log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId);
EdgeGrpcSession stale = sessionsById.remove(sessionId);
if (stale != null) {
try {
destroySession(stale);
log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId);
} catch (Exception e) {
log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId, e);
}
} else {
log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId);
}
}
edgeIdServiceIdCache.evict(edgeId);
}
private void destroySession(EdgeGrpcSession session) {
try (session) {
if (!session.destroy()) {
log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId());
zombieSessions.add(session);
}
} catch (Exception e) {
log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]",
session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId(), e);
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, long value) {
log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new LongDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new LongDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) {
log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new BooleanDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) {
try {
EdgeId edgeId = edge.getId();
ObjectNode edgeState = JacksonUtil.newObjectNode();
boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType);
if (isConnected) {
edgeState.put(ACTIVITY_STATE, true);
edgeState.put(LAST_CONNECT_TIME, ts);
} else {
edgeState.put(ACTIVITY_STATE, false);
edgeState.put(LAST_DISCONNECT_TIME, ts);
}
ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder()
.tenantId(tenantId)
.customerId(edge.getCustomerId())
.edgeId(edgeId)
.edgeName(edge.getName())
.connected(isConnected).build());
String data = JacksonUtil.toString(edgeState);
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg()
.type(msgType)
.originator(edgeId)
.copyMetaData(md)
.dataType(TbMsgDataType.JSON)
.data(data)
.build();
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e);
}
}
private void cleanupZombieSessions() {
try {
tryToDestroyZombieSessions(getZombieSessions(sessions.values()), s -> sessions.remove(s.getEdge().getId()));
tryToDestroyZombieSessions(getZombieSessions(sessionsById.values()), s -> sessionsById.remove(s.getSessionId()));
zombieSessions.removeIf(zombie -> {
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return false;
}
});
} catch (Exception e) {
log.warn("Failed to cleanup kafka sessions", e);
}
}
private List<EdgeGrpcSession> getZombieSessions(Collection<EdgeGrpcSession> sessions) {
List<EdgeGrpcSession> result = new ArrayList<>();
for (EdgeGrpcSession session : sessions) {
if (isKafkaSessionAndZombie(session)) {
result.add(session);
}
}
return result;
}
private void tryToDestroyZombieSessions(List<EdgeGrpcSession> sessionsToRemove, Function<EdgeGrpcSession, EdgeGrpcSession> removeFunc) {
for (EdgeGrpcSession toRemove : sessionsToRemove) {
log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getEdge().getId());
if (toRemove.destroy()) {
removeFunc.apply(toRemove);
}
}
}
private boolean isKafkaSessionAndZombie(EdgeGrpcSession session) {
if (session instanceof KafkaEdgeGrpcSession kafkaSession) {
log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}",
kafkaSession.getEdge().getId(),
kafkaSession.isConnected(),
kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null);
return !kafkaSession.isConnected() &&
kafkaSession.getConsumer() != null &&
kafkaSession.getConsumer().getConsumer() != null &&
!kafkaSession.getConsumer().getConsumer().isStopped();
}
return false;
}
}

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

File diff suppressed because it is too large

1
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java

@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import java.util.function.Consumer;

57
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java

@ -16,19 +16,70 @@
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.SettableFuture;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Data
@Getter
public class EdgeSessionState {
private final Map<Integer, DownlinkMsg> pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>());
@Setter
private SettableFuture<Boolean> sendDownlinkMsgsFuture;
@Setter
private ScheduledFuture<?> scheduledSendDownlinkTask;
@Setter
private volatile boolean connected;
@Setter
private EdgeVersion edgeVersion;
private final UUID sessionId = UUID.randomUUID();
private final Map<Integer, DownlinkMsg> pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>());
private final Lock sequenceDependencyLock = new ReentrantLock();
private final AtomicBoolean syncInProgress = new AtomicBoolean(false);
private TenantId tenantId;
private Edge edge;
public void setEdge(Edge edge) {
if (edge == null) {
return;
}
this.edge = edge;
this.tenantId = edge.getTenantId();
}
public EdgeId getEdgeId() {
return edge != null ? edge.getId() : null;
}
public boolean tryStartSync() {
return syncInProgress.compareAndSet(false, true);
}
public void finishSync() {
syncInProgress.set(false);
}
public boolean isSyncInProgress() {
return syncInProgress.get();
}
public void lockSequenceDependency() {
sequenceDependencyLock.lock();
}
public void unlockSequenceDependency() {
sequenceDependencyLock.unlock();
}
}

238
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java

@ -0,0 +1,238 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg;
import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg;
import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg;
import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityDataProto;
import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg;
import org.thingsboard.server.gen.edge.v1.RelationRequestMsg;
import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.ArrayList;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeUplinkMessageDispatcher {
private final EdgeContextComponent ctx;
public ListenableFuture<List<Void>> processUplinkMsg(EdgeSessionState state, UplinkMsg uplinkMsg) {
Edge edge = state.getEdge();
List<ListenableFuture<Void>> result = new ArrayList<>();
try {
if (uplinkMsg.getDeviceProfileUpdateMsgCount() > 0) {
for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : uplinkMsg.getDeviceProfileUpdateMsgList()) {
result.add(ctx.getDeviceProfileProcessor().processDeviceProfileMsgFromEdge(edge.getTenantId(), edge, deviceProfileUpdateMsg));
}
}
if (uplinkMsg.getDeviceUpdateMsgCount() > 0) {
for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceMsgFromEdge(edge.getTenantId(), edge, deviceUpdateMsg));
}
}
if (uplinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) {
for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg : uplinkMsg.getDeviceCredentialsUpdateMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceCredentialsMsgFromEdge(edge.getTenantId(), edge.getId(), deviceCredentialsUpdateMsg));
}
}
if (uplinkMsg.getAssetProfileUpdateMsgCount() > 0) {
for (AssetProfileUpdateMsg assetProfileUpdateMsg : uplinkMsg.getAssetProfileUpdateMsgList()) {
result.add(ctx.getAssetProfileProcessor().processAssetProfileMsgFromEdge(edge.getTenantId(), edge, assetProfileUpdateMsg));
}
}
if (uplinkMsg.getAssetUpdateMsgCount() > 0) {
for (AssetUpdateMsg assetUpdateMsg : uplinkMsg.getAssetUpdateMsgList()) {
result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg));
}
}
if (uplinkMsg.getRuleChainUpdateMsgCount() > 0) {
for (RuleChainUpdateMsg ruleChainUpdateMsg : uplinkMsg.getRuleChainUpdateMsgList()) {
result.add(ctx.getRuleChainProcessor().processRuleChainMsgFromEdge(edge.getTenantId(), edge, ruleChainUpdateMsg));
}
}
if (uplinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) {
for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg : uplinkMsg.getRuleChainMetadataUpdateMsgList()) {
result.add(ctx.getRuleChainProcessor().processRuleChainMetadataMsgFromEdge(edge.getTenantId(), edge, ruleChainMetadataUpdateMsg));
}
}
if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) {
for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) {
result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg));
}
}
if (uplinkMsg.getEntityDataCount() > 0) {
for (EntityDataProto entityData : uplinkMsg.getEntityDataList()) {
result.addAll(ctx.getTelemetryProcessor().processTelemetryMsg(edge.getTenantId(), entityData));
}
}
if (uplinkMsg.getAlarmUpdateMsgCount() > 0) {
for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) {
result.add(ctx.getAlarmProcessor().processAlarmMsgFromEdge(edge.getTenantId(), edge.getId(), alarmUpdateMsg));
}
}
if (uplinkMsg.getAlarmCommentUpdateMsgCount() > 0) {
for (AlarmCommentUpdateMsg alarmCommentUpdateMsg : uplinkMsg.getAlarmCommentUpdateMsgList()) {
result.add(ctx.getAlarmCommentProcessor().processAlarmCommentMsgFromEdge(edge.getTenantId(), edge.getId(), alarmCommentUpdateMsg));
}
}
if (uplinkMsg.getRelationUpdateMsgCount() > 0) {
for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) {
result.add(ctx.getRelationProcessor().processRelationMsgFromEdge(edge.getTenantId(), edge, relationUpdateMsg));
}
}
if (uplinkMsg.getDashboardUpdateMsgCount() > 0) {
for (DashboardUpdateMsg dashboardUpdateMsg : uplinkMsg.getDashboardUpdateMsgList()) {
result.add(ctx.getDashboardProcessor().processDashboardMsgFromEdge(edge.getTenantId(), edge, dashboardUpdateMsg));
}
}
if (uplinkMsg.getResourceUpdateMsgCount() > 0) {
for (ResourceUpdateMsg resourceUpdateMsg : uplinkMsg.getResourceUpdateMsgList()) {
result.add(ctx.getResourceProcessor().processResourceMsgFromEdge(edge.getTenantId(), edge, resourceUpdateMsg));
}
}
if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) {
for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg));
}
}
if (uplinkMsg.getAttributesRequestMsgCount() > 0) {
for (AttributesRequestMsg attributesRequestMsg : uplinkMsg.getAttributesRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processAttributesRequestMsg(edge.getTenantId(), edge, attributesRequestMsg));
}
}
if (uplinkMsg.getRelationRequestMsgCount() > 0) {
for (RelationRequestMsg relationRequestMsg : uplinkMsg.getRelationRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg));
}
}
if (uplinkMsg.getCalculatedFieldRequestMsgCount() > 0) {
for (CalculatedFieldRequestMsg calculatedFieldRequestMsg : uplinkMsg.getCalculatedFieldRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processCalculatedFieldRequestMsg(edge.getTenantId(), edge, calculatedFieldRequestMsg));
}
}
if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) {
for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg));
}
}
if (uplinkMsg.getDeviceCredentialsRequestMsgCount() > 0) {
for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg : uplinkMsg.getDeviceCredentialsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processDeviceCredentialsRequestMsg(edge.getTenantId(), edge, deviceCredentialsRequestMsg));
}
}
if (uplinkMsg.getDeviceRpcCallMsgCount() > 0) {
for (DeviceRpcCallMsg deviceRpcCallMsg : uplinkMsg.getDeviceRpcCallMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceRpcCallFromEdge(edge.getTenantId(), edge, deviceRpcCallMsg));
}
}
if (uplinkMsg.getWidgetBundleTypesRequestMsgCount() > 0) {
for (WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg : uplinkMsg.getWidgetBundleTypesRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processWidgetBundleTypesRequestMsg(edge.getTenantId(), edge, widgetBundleTypesRequestMsg));
}
}
if (uplinkMsg.getEntityViewsRequestMsgCount() > 0) {
for (EntityViewsRequestMsg entityViewRequestMsg : uplinkMsg.getEntityViewsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg));
}
}
if (uplinkMsg.getCalculatedFieldUpdateMsgCount() > 0) {
for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : uplinkMsg.getCalculatedFieldUpdateMsgList()) {
result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg));
}
}
if (uplinkMsg.getAiModelUpdateMsgCount() > 0) {
for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) {
result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg));
}
}
if (uplinkMsg.getUserUpdateMsgCount() > 0) {
for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) {
for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) {
for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
} catch (Exception e) {
String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
log.trace("[{}][{}] Can't process uplink msg [{}]", edge.getTenantId(), edge.getId(), uplinkMsg, e);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(edge.getTenantId()).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
return Futures.immediateFailedFuture(e);
}
return Futures.allAsList(result);
}
}

131
application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java

@ -0,0 +1,131 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc.EdgeRpcServiceImplBase;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class GrpcServer {
@Value("${edges.rpc.port}")
private int rpcPort;
@Value("${edges.rpc.ssl.enabled}")
private boolean sslEnabled;
@Value("${edges.rpc.ssl.cert}")
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
private int clientMaxKeepAliveTimeSec;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
private int maxInboundMessageSize;
@Value("${edges.rpc.keep_alive_time_sec:10}")
private int keepAliveTimeSec;
@Value("${edges.rpc.keep_alive_timeout_sec:5}")
private int keepAliveTimeoutSec;
private final EdgeRpcServiceImplBase edgeGrpcService;
private Server server;
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
public void onStartUp() {
log.info("Initializing Edge RPC server!");
NettyServerBuilder serverBuilder = NettyServerBuilder.forPort(rpcPort)
.permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS)
.permitKeepAliveWithoutCalls(true)
.maxInboundMessageSize(maxInboundMessageSize)
.addService(edgeGrpcService);
if (sslEnabled) {
setupSsl(serverBuilder);
}
server = serverBuilder.build();
log.info("Going to start Edge RPC server using port: {}", rpcPort);
try {
server.start();
} catch (IOException e) {
log.error("Failed to start Edge RPC server!", e);
throw new RuntimeException("Failed to start Edge RPC server!", e);
}
log.info("Edge RPC server initialized!");
}
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) {
try {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e);
}
}
@PreDestroy
public void destroy() {
if (server != null) {
server.shutdownNow();
}
}
}

186
application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java

@ -1,186 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
@Slf4j
public class KafkaEdgeGrpcSession extends EdgeGrpcSession {
private final TopicService topicService;
private final TbCoreQueueFactory tbCoreQueueFactory;
private final KafkaAdmin kafkaAdmin;
private volatile boolean isHighPriorityProcessing;
@Getter
private QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer;
private ExecutorService consumerExecutor;
public KafkaEdgeGrpcSession(EdgeContextComponent ctx, TopicService topicService, TbCoreQueueFactory tbCoreQueueFactory,
KafkaAdmin kafkaAdmin, StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSession> sessionOpenListener, BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService, int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
this.topicService = topicService;
this.tbCoreQueueFactory = tbCoreQueueFactory;
this.kafkaAdmin = kafkaAdmin;
}
private void processMsgs(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer) {
log.trace("[{}][{}] starting processing edge events", tenantId, edge.getId());
if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edge.getId(), isConnected(), isSyncInProgress(), isHighPriorityProcessing);
return;
}
List<EdgeEvent> edgeEvents = new ArrayList<>();
for (TbProtoQueueMsg<ToEdgeEventNotificationMsg> msg : msgs) {
EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
edgeEvents.add(edgeEvent);
}
List<DownlinkMsg> downlinkMsgsPack = convertToDownlinkMsgsPack(edgeEvents);
boolean isInterrupted = true;
try {
isInterrupted = sendDownlinkMsgsPack(downlinkMsgsPack).get();
if (isInterrupted) {
log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId());
}
} catch (Exception e) {
log.error("[{}][{}] Failed to process downlink messages", tenantId, edge.getId(), e);
}
if (!isInterrupted) {
consumer.commit();
}
}
@Override
public ListenableFuture<Boolean> migrateEdgeEvents() throws Exception {
return super.processEdgeEvents();
}
@Override
public ListenableFuture<Boolean> processEdgeEvents() {
if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edge != null ? edge.getId() : null, isConnected(), isSyncInProgress(), isHighPriorityProcessing);
return Futures.immediateFuture(Boolean.FALSE);
}
if (consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped())) {
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
try {
consumerExecutor.shutdown();
awaitConsumerTermination();
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edge.getId(), e);
}
}
this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer"));
this.consumer = QueueConsumerManager.<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>builder()
.name("TB Edge events [" + edge.getId() + "]")
.msgPackProcessor(this::processMsgs)
.pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval())
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId()))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edge.getId())
.build();
consumer.subscribe();
consumer.launch();
} catch (Exception e) {
destroy();
log.warn("[{}][{}] Failed to start edge event consumer", sessionId, edge.getId(), e);
}
}
return Futures.immediateFuture(Boolean.FALSE);
}
@Override
public void processHighPriorityEvents() {
isHighPriorityProcessing = true;
super.processHighPriorityEvents();
isHighPriorityProcessing = false;
}
@Override
public boolean destroy() {
try {
if (consumer != null) {
log.info("[{}][{}] Stopping edge event consumer...", tenantId, edge != null ? edge.getId() : null);
consumer.stop();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to stop edge event consumer", tenantId, edge.getId(), e);
return false;
}
consumer = null;
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
consumerExecutor.shutdown();
awaitConsumerTermination();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown edge event consumer executor", tenantId, edge.getId(), e);
return false;
}
return true;
}
private void awaitConsumerTermination() {
try {
consumerExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
} catch (InterruptedException ie) {
log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", tenantId, edge.getId());
}
}
@Override
public void cleanUp() {
String topic = topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edge.getId()).getTopic();
kafkaAdmin.deleteTopic(topic);
kafkaAdmin.deleteConsumerGroup(topic);
}
}

46
application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java

@ -1,46 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
@Slf4j
public class PostgresEdgeGrpcSession extends EdgeGrpcSession {
PostgresEdgeGrpcSession(EdgeContextComponent ctx, StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSession> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService,
int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
}
@Override
public ListenableFuture<Boolean> migrateEdgeEvents() {
return Futures.immediateFuture(Boolean.FALSE);
}
}

23
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor;
import com.google.common.util.concurrent.ListenableFuture;
public interface EdgeEventsDispatcher {
ListenableFuture<Boolean> processNewEvents() throws Exception;
}

218
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java

@ -0,0 +1,218 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.data.util.Pair;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
@Slf4j
@RequiredArgsConstructor
public class PostgresGeneralEdgeEventsDispatcher implements EdgeEventsDispatcher {
private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs";
private static final String QUEUE_START_SEQ_ID_ATTR_KEY = "queueStartSeqId";
private final EdgeSession session;
private final EdgeContextComponent ctx;
private Long newStartTs;
private Long previousStartTs;
private Long newStartSeqId;
private Long previousStartSeqId;
@Override
public ListenableFuture<Boolean> processNewEvents() throws Exception {
EdgeSessionState state = session.getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
UUID sessionId = state.getSessionId();
SettableFuture<Boolean> result = SettableFuture.create();
if (state.isConnected() && !state.isSyncInProgress()) {
setLocalPrevStartTsAndSeqId(tenantId, edgeId, sessionId);
GeneralEdgeEventFetcher fetcher = initGeneralEdgeEventFetcher();
log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}",
tenantId, edgeId, previousStartTs, previousStartSeqId);
Futures.addCallback(session.fetchAndSendEdgeEvents(fetcher), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Pair<Long, Long> newStartTsAndSeqId) {
if (newStartTsAndSeqId != null) {
Futures.addCallback(updateQueueStartTsAndSeqId(tenantId, edgeId, newStartTsAndSeqId), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable AttributesSaveResult saveResult) {
log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edgeId, newStartTsAndSeqId);
boolean newEventsAvailable;
if (fetcher.isSeqIdNewCycleStarted()) {
newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId);
} else {
newEventsAvailable = isSeqIdStartedNewCycle(tenantId, edgeId, sessionId);
if (!newEventsAvailable) {
newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId);
}
}
result.set(newEventsAvailable);
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edgeId, newStartTsAndSeqId, t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edgeId);
result.set(Boolean.FALSE);
}
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to process events", tenantId, edgeId, t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
if (state.isSyncInProgress()) {
log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edgeId);
result.set(Boolean.TRUE);
} else {
log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edgeId);
result.set(null);
}
}
return result;
}
private boolean isSeqIdStartedNewCycle(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
try {
log.trace("[{}][{}][{}] Checking if seq id started new cycle", tenantId, edgeId, sessionId);
TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService()
.findEdgeEvents(tenantId, edgeId, 0L, previousStartSeqId == 0 ? null : previousStartSeqId - 1, pageLink);
boolean result = !edgeEvents.getData().isEmpty();
log.trace("[{}][{}][{}] Result of check if seq id started new cycle, result = {}", tenantId, edgeId, sessionId, result);
return result;
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute isSeqIdStartedNewCycle", tenantId, edgeId, sessionId, e);
}
return false;
}
private boolean isNewEdgeEventsAvailable(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
try {
log.trace("[{}][{}][{}] Checking if new edge events available", tenantId, edgeId, sessionId);
TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService().findEdgeEvents(tenantId, edgeId, newStartSeqId, null, pageLink);
boolean available = !edgeEvents.getData().isEmpty() || session.hasHighPriorityEvents();
log.trace("[{}][{}][{}] Result of check if new edge events available, result = {}", tenantId, edgeId, sessionId, available);
return available;
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute isNewEdgeEventsAvailable", tenantId, edgeId, sessionId, e);
}
return false;
}
private ListenableFuture<AttributesSaveResult> updateQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, Pair<Long, Long> pair) {
newStartTs = pair.getFirst();
newStartSeqId = pair.getSecond();
log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", session.getState().getSessionId(), edgeId, newStartTs, newStartSeqId);
List<AttributeKvEntry> attributes = List.of(
new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis()),
new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis())
);
return ctx.getAttributesService().save(tenantId, edgeId, AttributeScope.SERVER_SCOPE, attributes);
}
private GeneralEdgeEventFetcher initGeneralEdgeEventFetcher() {
return new GeneralEdgeEventFetcher(
previousStartTs,
previousStartSeqId,
false,
Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()),
ctx.getEdgeEventService(),
ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis());
}
private void setLocalPrevStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) throws InterruptedException, ExecutionException {
Pair<Long, Long> startTsAndSeqId = getQueueStartTsAndSeqId(tenantId, edgeId, sessionId).get();
previousStartTs = startTsAndSeqId.getFirst();
previousStartSeqId = startTsAndSeqId.getSecond();
}
private ListenableFuture<Pair<Long, Long>> getQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
ListenableFuture<List<AttributeKvEntry>> future = ctx.getAttributesService()
.find(tenantId, edgeId, AttributeScope.SERVER_SCOPE, Arrays.asList(QUEUE_START_TS_ATTR_KEY, QUEUE_START_SEQ_ID_ATTR_KEY));
return Futures.transform(future, attributeKvEntries -> {
long startTs = 0L;
long startSeqId = 0L;
for (AttributeKvEntry attributeKvEntry : attributeKvEntries) {
if (QUEUE_START_TS_ATTR_KEY.equals(attributeKvEntry.getKey())) {
startTs = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L;
}
if (QUEUE_START_SEQ_ID_ATTR_KEY.equals(attributeKvEntry.getKey())) {
startSeqId = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L;
}
}
if (startSeqId == 0L) {
startSeqId = findStartSeqIdFromOldestEventIfAny(tenantId, edgeId, sessionId);
}
return Pair.of(startTs, startSeqId);
}, ctx.getGrpcCallbackExecutorService());
}
private long findStartSeqIdFromOldestEventIfAny(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
long startSeqId = 0L;
try {
TimePageLink pageLink = new TimePageLink(1, 0, null, null, null, null);
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService()
.findEdgeEvents(tenantId, edgeId, null, null, pageLink);
if (!edgeEvents.getData().isEmpty()) {
startSeqId = edgeEvents.getData().get(0).getSeqId() - 1;
}
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute findStartSeqIdFromOldestEventIfAny", tenantId, edgeId, sessionId, e);
}
return startSeqId;
}
}

392
application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java

@ -0,0 +1,392 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.service;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.EdgeAttributeSaveCallback;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME;
@Service
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
@Value("${edges.send_scheduler_pool_size}")
private int sendSchedulerPoolSize;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Autowired
@Lazy
private EdgeContextComponent ctx;
private final EdgeSessionsHolder sessions;
private final ApplicationContext applicationContext;
private final TbClusterService clusterService;
private final TbServiceInfoProvider serviceInfoProvider;
private final TelemetrySubscriptionService tsSubService;
private final TbTransactionalCache<EdgeId, String> edgeIdServiceIdCache;
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private ScheduledExecutorService executorService;
private ScheduledExecutorService sendDownlinkExecutorService;
@PostConstruct
public void onStartUp() {
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
}
@PreDestroy
private void preDestroy() {
shutdownExecutorSafely(executorService);
shutdownExecutorSafely(sendDownlinkExecutorService);
sessions.forEach(EdgeGrpcSessionManager::onEdgeDisconnect);
}
@Override
public StreamObserver<RequestMsg> handleMsgs(StreamObserver<ResponseMsg> outputStream) {
EdgeGrpcSessionManager sessionManager = applicationContext.getBean(EdgeGrpcSessionManager.class);
return sessionManager.initInputStream(outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService);
}
@Override
public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) {
switch (msg.getMsgType()) {
case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> {
EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg;
log.trace("[{}] edgeEventMsg [{}]", tenantId, msg);
onEdgeHighPriorityEvent(edgeHighPriorityMsg);
}
case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> {
EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg;
log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg);
onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId());
}
case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> {
ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg;
log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg);
startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId());
}
case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> {
FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg;
log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg);
processSyncResponse(fromEdgeSyncResponse);
}
}
}
@Override
public void updateEdge(TenantId tenantId, Edge edge) {
if (edge == null) {
log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId);
return;
}
EdgeGrpcSessionManager session = sessions.getByEdgeId(edge.getId());
if (session != null && session.getState().isConnected()) {
log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
session.onConfigurationUpdate(edge);
} else {
log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
}
}
@Override
public void deleteEdge(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId);
session.destroyAndMarkAsZombieIfFailed();
session.onEdgeRemoval();
sessions.remove(session);
}
}
@Override
public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer<FromEdgeSyncResponse> responseConsumer) {
ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId());
UUID requestId = request.getId();
EdgeGrpcSessionManager session = sessions.getByEdgeId(request.getEdgeId());
if (session != null && session.getState().isSyncInProgress()) {
responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment"));
} else {
log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId());
localSyncEdgeRequests.put(requestId, responseConsumer);
clusterService.pushEdgeSyncRequestToEdge(request);
scheduleSyncRequestTimeout(request, requestId);
}
}
private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSessionManager edgeSession) {
EdgeSessionState state = edgeSession.getState();
Edge edge = state.getEdge();
TenantId tenantId = state.getTenantId();
log.info("[{}][{}] edge [{}] connected successfully.", tenantId, state.getSessionId(), edgeId);
if (sessions.hasByEdgeId(edgeId)) {
EdgeGrpcSessionManager existingSession = sessions.getByEdgeId(edgeId);
if (existingSession != null) {
UUID sessionId = existingSession.getState().getSessionId();
log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, state.getSessionId(), sessionId, edgeId);
existingSession.destroyAndMarkAsZombieIfFailed();
sessions.removeBySessionId(sessionId);
}
}
sessions.put(edgeSession);
save(tenantId, edgeId, ACTIVITY_STATE, true);
long lastConnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs);
edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId());
pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT);
edgeSession.onEdgeConnect();
}
private void onEdgeDisconnect(Edge edge, UUID sessionId) {
TenantId tenantId = edge.getTenantId();
EdgeId edgeId = edge.getId();
log.info("[{}][{}] edge disconnected!", edgeId, sessionId);
EdgeGrpcSessionManager current = sessions.getByEdgeId(edgeId);
if (current != null && current.getState().getSessionId().equals(sessionId)) {
EdgeGrpcSessionManager toRemove = sessions.removeByEdgeId(edgeId);
if (toRemove != null) {
toRemove.onEdgeDisconnect();
toRemove.destroyAndMarkAsZombieIfFailed();
}
sessions.removeBySessionId(sessionId);
save(tenantId, edgeId, ACTIVITY_STATE, false);
long lastDisconnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs);
pushRuleEngineMessage(tenantId, edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT);
} else {
log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId);
EdgeGrpcSessionManager stale = sessions.removeBySessionId(sessionId);
if (stale != null) {
try {
stale.destroyAndMarkAsZombieIfFailed();
log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId);
} catch (Exception e) {
log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId, e);
}
} else {
log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId);
}
}
edgeIdServiceIdCache.evict(edgeId);
}
private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) {
log.trace("[{}] scheduling sync edge request", requestId);
executorService.schedule(() -> {
log.trace("[{}] checking if sync edge request is not processed...", requestId);
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
log.trace("[{}] timeout for processing sync edge request.", requestId);
consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected"));
}
}, 20, TimeUnit.SECONDS);
}
private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) {
TenantId tenantId = msg.getTenantId();
EdgeEvent edgeEvent = msg.getEdgeEvent();
EdgeId edgeId = edgeEvent.getEdgeId();
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId);
session.addEventToHighPriorityQueue(edgeEvent);
}
}
private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId());
session.onEdgeEventUpdate();
}
}
private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null) {
EdgeSessionState sessionState = session.getState();
if (sessionState.isSyncInProgress()) {
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId);
} else {
boolean success = false;
if (sessionState.isConnected()) {
session.startSyncProcess(true);
success = true;
}
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId);
}
}
}
private void processSyncResponse(FromEdgeSyncResponse response) {
log.trace("[{}] Received response from sync service: [{}]", response.getId(), response);
UUID requestId = response.getId();
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
consumer.accept(response);
} else {
log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response);
}
}
private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) {
try {
EdgeId edgeId = edge.getId();
ObjectNode edgeState = JacksonUtil.newObjectNode();
boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType);
if (isConnected) {
edgeState.put(ACTIVITY_STATE, true);
edgeState.put(LAST_CONNECT_TIME, ts);
} else {
edgeState.put(ACTIVITY_STATE, false);
edgeState.put(LAST_DISCONNECT_TIME, ts);
}
ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder()
.tenantId(tenantId)
.customerId(edge.getCustomerId())
.edgeId(edgeId)
.edgeName(edge.getName())
.connected(isConnected).build());
String data = JacksonUtil.toString(edgeState);
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg()
.type(msgType)
.originator(edgeId)
.copyMetaData(md)
.dataType(TbMsgDataType.JSON)
.data(data)
.build();
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e);
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, long value) {
log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
tsSubService.saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new LongDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new LongDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) {
log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
tsSubService.saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new BooleanDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void shutdownExecutorSafely(ExecutorService e) {
if (e != null && !e.isShutdown()) {
e.shutdown();
}
}
}

131
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java

@ -0,0 +1,131 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import org.thingsboard.server.service.edge.rpc.session.manager.KafkaBasedEdgeGrpcSessionManager;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@Service
@Slf4j
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class DefaultZombieSessionCleanupService implements ZombieSessionCleanupService {
@Autowired
private EdgeSessionsHolder edgeSessionsHolder;
private final Queue<EdgeGrpcSessionManager> zombieSessions;
private final ScheduledExecutorService zombieSessionsExecutorService;
public DefaultZombieSessionCleanupService() {
this.zombieSessions = new ConcurrentLinkedQueue<>();
this.zombieSessionsExecutorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("zombie-sessions");
}
@PostConstruct
public void init() {
this.zombieSessionsExecutorService.scheduleAtFixedRate(this::cleanupZombieSessions, 30, 60, TimeUnit.SECONDS);
}
@PreDestroy
public void destroy() {
if (zombieSessionsExecutorService != null && !zombieSessionsExecutorService.isShutdown()) {
zombieSessionsExecutorService.shutdown();
}
}
@Override
public void add(EdgeGrpcSessionManager session) {
zombieSessions.add(session);
}
private void cleanupZombieSessions() {
try {
tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessions().values()),
s -> edgeSessionsHolder.removeByEdgeId(s.getState().getEdge().getId()));
tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessionsById().values()),
s -> edgeSessionsHolder.removeBySessionId(s.getState().getSessionId()));
zombieSessions.removeIf(zombie -> {
EdgeSessionState state = zombie.getState();
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName());
return false;
}
});
} catch (Exception e) {
log.warn("Failed to cleanup zombie sessions", e);
}
}
private List<EdgeGrpcSessionManager> getZombieSessions(Collection<EdgeGrpcSessionManager> sessions) {
List<EdgeGrpcSessionManager> result = new ArrayList<>();
for (EdgeGrpcSessionManager session : sessions) {
if (isKafkaSessionAndZombie(session)) {
result.add(session);
}
}
return result;
}
private boolean isKafkaSessionAndZombie(EdgeGrpcSessionManager session) {
if (session instanceof KafkaBasedEdgeGrpcSessionManager kafkaSession) {
EdgeSessionState sessionState = kafkaSession.getState();
log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}",
sessionState.getEdgeId(),
sessionState.isConnected(),
kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null);
return !sessionState.isConnected() &&
kafkaSession.getConsumer() != null &&
kafkaSession.getConsumer().getConsumer() != null &&
!kafkaSession.getConsumer().getConsumer().isStopped();
}
return false;
}
private void tryToDestroyZombieSessions(List<EdgeGrpcSessionManager> sessionsToRemove, Function<EdgeGrpcSessionManager, EdgeGrpcSessionManager> removeFunc) {
for (EdgeGrpcSessionManager toRemove : sessionsToRemove) {
log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getState().getEdge().getId());
if (toRemove.destroy()) {
removeFunc.apply(toRemove);
}
}
}
}

16
application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java → application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java

@ -13,24 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
package org.thingsboard.server.service.edge.rpc.session;
import com.google.common.util.concurrent.FutureCallback;
import jakarta.annotation.Nullable;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
@Slf4j
@AllArgsConstructor
public class AttributeSaveCallback implements FutureCallback<Void> {
public class EdgeAttributeSaveCallback implements FutureCallback<Void> {
private final TenantId tenantId;
private final EdgeId edgeId;
private final String key;
private final Object value;
public EdgeAttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) {
this.tenantId = tenantId;
this.edgeId = edgeId;
this.key = key;
this.value = value;
}
@Override
public void onSuccess(@Nullable Void result) {
log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value);
@ -40,4 +45,5 @@ public class AttributeSaveCallback implements FutureCallback<Void> {
public void onFailure(Throwable t) {
log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t);
}
}
}

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

@ -0,0 +1,621 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.data.util.Pair;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.dao.edge.stats.EdgeStatsKey;
import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg;
import org.thingsboard.server.gen.edge.v1.ConnectResponseCode;
import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg;
import org.thingsboard.server.gen.edge.v1.EdgeConfiguration;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsgType;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.EdgeSyncCursor;
import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher;
import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@Slf4j
@RequiredArgsConstructor
public class EdgeGrpcSession implements EdgeSession {
private static final int MAX_DOWNLINK_ATTEMPTS = 3;
private static final String RATE_LIMIT_REACHED = "Rate limit reached";
private final EdgeGrpcSessionManager parentManagerRef;
private final EdgeContextComponent ctx;
private final StreamObserver<ResponseMsg> outputStream;
private final DownlinkMessageMapper downlinkMessageMapper;
private final EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
private final BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener;
private final BiConsumer<Edge, UUID> sessionCloseListener;
private final ScheduledExecutorService sendDownlinkExecutorService;
private final int maxInboundMessageSize;
private final int maxHighPriorityQueueSizePerSession;
private final EdgeSessionState state = new EdgeSessionState();
private final Lock downlinkMsgLock = new ReentrantLock();
private final ConcurrentLinkedQueue<EdgeEvent> highPriorityQueue = new ConcurrentLinkedQueue<>();
private int clientMaxInboundMessageSize;
@Override
public EdgeSessionState getState() {
return state;
}
@Override
public StreamObserver<RequestMsg> initInputStream() {
return new StreamObserver<>() {
@Override
public void onNext(RequestMsg requestMsg) {
if (!state.isConnected() && requestMsg.getMsgType().equals(RequestMsgType.CONNECT_RPC_MESSAGE)) {
ConnectResponseMsg responseMsg = processConnect(requestMsg.getConnectRequestMsg());
outputStream.onNext(ResponseMsg.newBuilder()
.setConnectResponseMsg(responseMsg)
.build());
if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) {
outputStream.onError(new RuntimeException(responseMsg.getErrorMsg()));
} else {
if (requestMsg.getConnectRequestMsg().hasMaxInboundMessageSize()) {
log.debug("[{}][{}] Client max inbound message size: {}",
getTenantId(), getSessionId(), requestMsg.getConnectRequestMsg().getMaxInboundMessageSize());
clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize();
}
state.setConnected(true);
}
}
if (state.isConnected()) {
if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) {
if (requestMsg.hasSyncRequestMsg()) {
boolean fullSync = false;
if (requestMsg.getSyncRequestMsg().hasFullSync()) {
fullSync = requestMsg.getSyncRequestMsg().getFullSync();
}
startSyncProcess(fullSync);
} else {
state.finishSync();
saveSyncInProgressAsAttribute(false);
}
}
if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) {
if (requestMsg.hasUplinkMsg()) {
onUplinkMsg(requestMsg.getUplinkMsg());
}
if (requestMsg.hasDownlinkResponseMsg()) {
onDownlinkResponse(requestMsg.getDownlinkResponseMsg());
}
}
}
}
@Override
public void onError(Throwable t) {
log.trace("[{}][{}] Stream was terminated due to error:", getTenantId(), getSessionId(), t);
closeSession();
}
@Override
public void onCompleted() {
log.info("[{}][{}] Stream was closed and completed successfully!", getTenantId(), getSessionId());
closeSession();
}
private void closeSession() {
state.setConnected(false);
if (state.getEdge() != null) {
try {
sessionCloseListener.accept(state.getEdge(), getSessionId());
} catch (Exception ignored) {
}
}
try {
outputStream.onCompleted();
} catch (Exception ignored) {
}
}
};
}
@Override
public void startSyncProcess(boolean fullSync) {
if (state.tryStartSync()) {
saveSyncInProgressAsAttribute(true);
log.info("[{}][{}][{}] Staring edge sync process", getTenantId(), getEdgeId(), getSessionId());
interruptGeneralProcessingOnSync();
doSync(new EdgeSyncCursor(ctx, state.getEdge(), fullSync));
} else {
log.info("[{}][{}][{}] Sync is already started, skipping starting it now", getTenantId(), getEdgeId(), getSessionId());
}
}
@Override
public void sendDownlinkMsg(ResponseMsg responseMsg) {
if (state.isConnected()) {
String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000);
log.trace("[{}][{}] Sending downlink msg [{}]", getTenantId(), getEdgeId(), responseMsgStr);
downlinkMsgLock.lock();
String downlinkMsgStr = responseMsg.hasDownlinkMsg() ? String.valueOf(responseMsg.getDownlinkMsg().getDownlinkMsgId()) : responseMsgStr;
try {
outputStream.onNext(responseMsg);
} catch (Exception e) {
log.trace("[{}][{}] Failed to send downlink message [{}]", getTenantId(), getEdgeId(), downlinkMsgStr, e);
state.setConnected(false);
sessionCloseListener.accept(state.getEdge(), getSessionId());
} finally {
downlinkMsgLock.unlock();
}
log.trace("[{}][{}] downlink msg successfully sent [{}]", getTenantId(), getEdgeId(), downlinkMsgStr);
}
}
@Override
public void addHighPriorityEvent(EdgeEvent edgeEvent) {
while (highPriorityQueue.size() > maxHighPriorityQueueSizePerSession) {
EdgeEvent oldestHighPriority = highPriorityQueue.poll();
if (oldestHighPriority != null) {
log.warn("[{}][{}][{}] High priority queue is full. Removing oldest high priority event from queue {}",
state.getTenantId(), state.getEdgeId(), getSessionId(), oldestHighPriority);
}
}
highPriorityQueue.add(edgeEvent);
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_ADDED, state.getEdge().getTenantId(), edgeEvent.getEdgeId(), 1));
}
@Override
public void processHighPriorityEvents() {
try {
if (state.isConnected() && !state.isSyncInProgress()) {
if (highPriorityQueue.isEmpty()) {
return;
}
List<EdgeEvent> highPriorityEvents = new ArrayList<>();
EdgeEvent event;
while ((event = highPriorityQueue.poll()) != null) {
highPriorityEvents.add(event);
}
log.trace("[{}][{}] Sending high priority events {}", getTenantId(), getEdgeId(), highPriorityEvents.size());
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, highPriorityEvents);
sendDownlinkMsgsPack(downlinkMsgsPack).get();
}
} catch (Exception e) {
log.error("[{}] Failed to process high priority events", getEdgeId(), e);
}
}
@Override
public boolean hasHighPriorityEvents() {
return !highPriorityQueue.isEmpty();
}
@Override
public ListenableFuture<Pair<Long, Long>> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher) {
SettableFuture<Pair<Long, Long>> result = SettableFuture.create();
PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount());
fetchAndSendEdgeEvents(fetcher, pageLink, result);
return result;
}
@Override
public ListenableFuture<Boolean> sendDownlinkMsgsPack(List<DownlinkMsg> downlinkMsgsPack) {
interruptPreviousSendDownlinkMsgsTask();
state.setSendDownlinkMsgsFuture(SettableFuture.create());
state.getPendingMsgsMap().clear();
downlinkMsgsPack.forEach(msg -> state.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg));
scheduleDownlinkMsgsPackSend(1);
return state.getSendDownlinkMsgsFuture();
}
@Override
public void close() {
log.debug("[{}][{}] Closing session", getTenantId(), getSessionId());
state.setConnected(false);
try {
outputStream.onCompleted();
} catch (Exception e) {
log.debug("[{}][{}] Failed to close output stream: {}", getTenantId(), getSessionId(), e.getMessage());
}
}
private void onUplinkMsg(UplinkMsg uplinkMsg) {
if (isRateLimitViolated(uplinkMsg)) {
return;
}
Futures.addCallback(uplinkMessageDispatcher.processUplinkMsg(state, uplinkMsg), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable List<Void> result) {
sendResponseMessage(uplinkMsg.getUplinkMsgId(), true, null);
}
@Override
public void onFailure(Throwable t) {
String errorMsg = EdgeUtils.createErrorMsgFromRootCauseAndStackTrace(t);
sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
}
}, ctx.getGrpcCallbackExecutorService());
}
private boolean isRateLimitViolated(UplinkMsg uplinkMsg) {
if (!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES, getTenantId()) ||
!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, getTenantId(), getEdgeId())) {
String errorMsg = String.format("Failed to process uplink message. %s", RATE_LIMIT_REACHED);
sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
return true;
}
return false;
}
private void sendResponseMessage(int uplinkMsgId, boolean success, String errorMsg) {
UplinkResponseMsg.Builder responseBuilder = UplinkResponseMsg.newBuilder()
.setUplinkMsgId(uplinkMsgId)
.setSuccess(success);
if (errorMsg != null) {
responseBuilder.setErrorMsg(errorMsg);
}
sendDownlinkMsg(ResponseMsg.newBuilder()
.setUplinkResponseMsg(responseBuilder.build())
.build());
}
private void onDownlinkResponse(DownlinkResponseMsg msg) {
try {
if (msg.getSuccess()) {
state.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
ctx.getStatsCounterService().ifPresent(statsCounterService -> statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PUSHED, getTenantId(), getEdgeId(), 1));
log.debug("[{}][{}][{}] Msg has been processed successfully! Msg Id: [{}], Msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg);
} else {
log.debug("[{}][{}][{}] Msg processing failed! Msg Id: [{}], Error msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg.getErrorMsg());
DownlinkMsg downlinkMsg = state.getPendingMsgsMap().get(msg.getDownlinkMsgId());
// if NOT timeseries or attributes failures - ack failed downlink
if (downlinkMsg != null && downlinkMsg.getEntityDataCount() == 0) {
state.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
}
}
if (state.getPendingMsgsMap().isEmpty()) {
log.debug("[{}][{}][{}] Pending msgs map is empty. Stopping current iteration", getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(false);
}
} catch (Exception e) {
log.error("[{}][{}] Can't process downlink response message [{}]", getTenantId(), getEdgeId(), msg, e);
}
}
private void interruptGeneralProcessingOnSync() {
log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(true);
}
private void doSync(EdgeSyncCursor cursor) {
if (cursor.hasNext()) {
EdgeEventFetcher next = cursor.getNext();
log.debug("[{}][{}] starting sync process, cursor current idx = {}, class = {}",
getTenantId(), getEdgeId(), cursor.getCurrentIdx(), next.getClass().getSimpleName());
ListenableFuture<Pair<Long, Long>> future = fetchAndSendEdgeEvents(next);
Futures.addCallback(future, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Pair<Long, Long> result) {
doSync(cursor);
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Exception during sync process", getTenantId(), getEdgeId(), t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.info("[{}][{}] sync process completed", getTenantId(), getEdgeId());
DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder()
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build())
.build();
Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback<>() {
@Override
public void onSuccess(Boolean isInterrupted) {
markSyncCompletedSendEdgeEventUpdate();
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Exception during sending sync complete", getTenantId(), getEdgeId(), t);
markSyncCompletedSendEdgeEventUpdate();
}
}, ctx.getGrpcCallbackExecutorService());
}
}
private void markSyncCompletedSendEdgeEventUpdate() {
state.finishSync();
saveSyncInProgressAsAttribute(false);
ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(getTenantId(), getEdgeId()));
}
private void fetchAndSendEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture<Pair<Long, Long>> result) {
Edge edge = state.getEdge();
TenantId tenantId = getTenantId();
try {
log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
processHighPriorityEvents();
PageData<EdgeEvent> pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink);
if (state.isConnected() && !pageData.getData().isEmpty()) {
if (fetcher instanceof GeneralEdgeEventFetcher) {
long queueSize = pageData.getTotalElements() - ((long) pageLink.getPageSize() * pageLink.getPage());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_LAG, tenantId, edge.getId(), queueSize));
}
log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, edge.getId(), pageData.getData().size());
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, pageData.getData());
Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Boolean isInterrupted) {
if (Boolean.TRUE.equals(isInterrupted)) {
log.debug("[{}][{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId(), getSessionId());
result.set(null);
} else {
if (state.isConnected() && pageData.hasNext()) {
fetchAndSendEdgeEvents(fetcher, pageLink.nextPageLink(), result);
} else {
EdgeEvent latestEdgeEvent = pageData.getData().get(pageData.getData().size() - 1);
UUID idOffset = latestEdgeEvent.getUuidId();
if (idOffset != null) {
Long newStartTs = Uuids.unixTimestamp(idOffset);
long newStartSeqId = latestEdgeEvent.getSeqId();
result.set(Pair.of(newStartTs, newStartSeqId));
} else {
result.set(null);
}
}
}
}
@Override
public void onFailure(Throwable t) {
log.error("[{}] Failed to send downlink msgs pack", edge.getId(), t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
result.set(null);
}
} catch (Exception e) {
log.error("[{}] Failed to fetch edge events", edge.getId(), e);
result.setException(e);
}
}
private void scheduleDownlinkMsgsPackSend(int attempt) {
Runnable sendDownlinkMsgsTask = () -> {
try {
if (!state.isConnected()) {
stopCurrentSendDownlinkMsgsTask(true);
return;
}
if (!state.getPendingMsgsMap().values().isEmpty()) {
Edge edge = state.getEdge();
List<DownlinkMsg> copy = new ArrayList<>(state.getPendingMsgsMap().values());
if (attempt > 1) {
String error = "Failed to deliver the batch";
String failureMsg = String.format("{%s} (size: {%s})", error, copy.size());
if (attempt == 2) {
// Send a failure notification only on the second attempt.
// This ensures that failure alerts are sent just once to avoid redundant notifications.
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId())
.edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
}
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED, edge.getTenantId(), getEdgeId(), 1));
log.warn("[{}][{}] {} on attempt {}", getTenantId(), getEdgeId(), failureMsg, attempt);
log.debug("[{}][{}] entities in failed batch: {}", getTenantId(), getEdgeId(), copy);
}
log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", getTenantId(), getEdgeId(), copy.size());
for (DownlinkMsg downlinkMsg : copy) {
if (clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > clientMaxInboundMessageSize) {
String error = String.format("Client max inbound message size %s is exceeded. Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE " +
"env variable on the edge and restart it.", clientMaxInboundMessageSize);
String message = String.format("Downlink msg size %s exceeds client max inbound message size %s. " +
"Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE env variable on the edge and restart it.", downlinkMsg.getSerializedSize(), clientMaxInboundMessageSize);
log.error("[{}][{}][{}] {} Message {}", getTenantId(), getEdgeId(), getSessionId(), message, downlinkMsg);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId())
.edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(message).error(error).build());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), 1));
state.getPendingMsgsMap().remove(downlinkMsg.getDownlinkMsgId());
} else {
sendDownlinkMsg(ResponseMsg.newBuilder()
.setDownlinkMsg(downlinkMsg)
.build());
}
}
if (attempt < MAX_DOWNLINK_ATTEMPTS) {
scheduleDownlinkMsgsPackSend(attempt + 1);
} else {
String failureMsg = String.format("Failed to deliver messages: %s", copy);
log.warn("[{}][{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}",
getTenantId(), getEdgeId(), MAX_DOWNLINK_ATTEMPTS, copy);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId()).edgeId(getEdgeId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg)
.error("Failed to deliver messages after " + MAX_DOWNLINK_ATTEMPTS + " attempts").build());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), copy.size()));
stopCurrentSendDownlinkMsgsTask(false);
}
} else {
stopCurrentSendDownlinkMsgsTask(false);
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", getTenantId(), getEdgeId(), e.getMessage(), e);
stopCurrentSendDownlinkMsgsTask(true);
}
};
if (attempt == 1) {
sendDownlinkExecutorService.submit(sendDownlinkMsgsTask);
} else {
state.setScheduledSendDownlinkTask(
sendDownlinkExecutorService.schedule(
sendDownlinkMsgsTask,
ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(),
TimeUnit.MILLISECONDS)
);
}
}
private void interruptPreviousSendDownlinkMsgsTask() {
if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()
|| state.getScheduledSendDownlinkTask() != null && !state.getScheduledSendDownlinkTask().isCancelled()) {
log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!",
getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(true);
} else {
log.trace("[{}][{}][{}] Previous send downlink future is not active",
getTenantId(), getEdgeId(), getSessionId());
}
}
private void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) {
if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()) {
state.getSendDownlinkMsgsFuture().set(isInterrupted);
}
if (state.getScheduledSendDownlinkTask() != null) {
state.getScheduledSendDownlinkTask().cancel(true);
}
}
private ConnectResponseMsg processConnect(ConnectRequestMsg request) {
log.trace("[{}] processConnect [{}]", getSessionId(), request);
Optional<Edge> optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey());
if (optional.isPresent()) {
Edge edge = optional.get();
TenantId tenantId = edge.getTenantId();
state.setEdge(edge);
try {
if (edge.getSecret().equals(request.getEdgeSecret())) {
sessionOpenListener.accept(edge.getId(), parentManagerRef);
state.setEdgeVersion(request.getEdgeVersion());
processSaveEdgeVersionAsAttribute(request.getEdgeVersion().name());
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.ACCEPTED)
.setErrorMsg("")
.setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge))
.setMaxInboundMessageSize(maxInboundMessageSize)
.build();
}
String error = "Failed to validate the edge!";
String failureMsg = String.format("%s Provided request secret: %s", error, request.getEdgeSecret());
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.BAD_CREDENTIALS)
.setErrorMsg(failureMsg)
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
} catch (Exception e) {
String failureMsg = "Failed to process edge connection!";
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
log.error(failureMsg, e);
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.SERVER_UNAVAILABLE)
.setErrorMsg(failureMsg)
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
}
}
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.BAD_CREDENTIALS)
.setErrorMsg("Failed to find the edge! Routing key: " + request.getEdgeRoutingKey())
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
}
private void processSaveEdgeVersionAsAttribute(String edgeVersion) {
AttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(new StringDataEntry(DataConstants.EDGE_VERSION_ATTR_KEY, edgeVersion), System.currentTimeMillis());
ctx.getAttributesService().save(getTenantId(), getEdgeId(), AttributeScope.SERVER_SCOPE, attributeKvEntry);
}
private void saveSyncInProgressAsAttribute(Boolean value) {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(getTenantId())
.entityId(getEdgeId())
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
.callback(new EdgeAttributeSaveCallback(getTenantId(), getEdgeId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
.build());
}
private TenantId getTenantId() {
return state.getTenantId();
}
private EdgeId getEdgeId() {
return state.getEdgeId();
}
private UUID getSessionId() {
return state.getSessionId();
}
}

34
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
public abstract class EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager {
protected abstract EdgeSession getSession();
@Override
public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) {
getSession().addHighPriorityEvent(edgeEvent);
}
@Override
public void startSyncProcess(boolean fullSync) {
getSession().startSyncProcess(fullSync);
}
}

43
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java

@ -0,0 +1,43 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import org.springframework.data.util.Pair;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher;
import java.io.Closeable;
import java.util.List;
public interface EdgeSession extends Closeable {
StreamObserver<RequestMsg> initInputStream();
EdgeSessionState getState();
void startSyncProcess(boolean fullSync);
void sendDownlinkMsg(ResponseMsg responseMsg);
void addHighPriorityEvent(EdgeEvent edgeEvent);
void processHighPriorityEvents();
boolean hasHighPriorityEvents();
ListenableFuture<Pair<Long, Long>> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher);
ListenableFuture<Boolean> sendDownlinkMsgsPack(List<DownlinkMsg> downlinkMsgsPack);
}

83
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java

@ -0,0 +1,83 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
@Data
@Slf4j
@Component
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeSessionsHolder {
private final ConcurrentMap<EdgeId, EdgeGrpcSessionManager> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, EdgeGrpcSessionManager> sessionsById = new ConcurrentHashMap<>();
public void forEach(Consumer<EdgeGrpcSessionManager> consumer) {
Set<EdgeGrpcSessionManager> unique = new HashSet<>(sessions.values());
unique.addAll(sessionsById.values());
unique.forEach(consumer);
}
public void put(EdgeGrpcSessionManager session) {
UUID sessionId = session.getState().getSessionId();
EdgeId edgeId = session.getState().getEdgeId();
sessionsById.put(sessionId, session);
sessions.put(edgeId, session);
}
public EdgeGrpcSessionManager getByEdgeId(EdgeId id) {
return sessions.get(id);
}
public boolean hasByEdgeId(EdgeId id) {
return sessions.containsKey(id);
}
public EdgeGrpcSessionManager removeByEdgeId(EdgeId id) {
return sessions.remove(id);
}
public EdgeGrpcSessionManager removeBySessionId(UUID sessionId) {
return sessionsById.remove(sessionId);
}
public void remove(EdgeGrpcSessionManager session) {
if (session == null) {
log.warn("Can't remove session from holder because it's null");
return;
}
EdgeSessionState sessionState = session.getState();
removeByEdgeId(sessionState.getEdgeId());
removeBySessionId(sessionState.getSessionId());
}
}

23
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
public interface ZombieSessionCleanupService {
void add(EdgeGrpcSessionManager session);
}

149
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java

@ -0,0 +1,149 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSession;
import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSessionDelegate;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import org.thingsboard.server.service.edge.rpc.session.ZombieSessionCleanupService;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@Slf4j
public abstract class AbstractEdgeGrpcSessionManager extends EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager {
@Value("${edges.max_high_priority_queue_size_per_session:10000}")
protected int maxHighPriorityQueueSizePerSession;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
protected int maxInboundMessageSize;
@Autowired
protected ZombieSessionCleanupService zombieSessionCleanupService;
@Autowired
@Lazy
protected EdgeContextComponent ctx;
@Autowired
protected DownlinkMessageMapper downlinkMessageMapper;
@Autowired
protected EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
@Getter
protected EdgeSession session;
private final Lock initLock = new ReentrantLock();
@Override
public StreamObserver<RequestMsg> initInputStream(StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService) {
if (session != null) {
throw new IllegalStateException("Session already initialized");
}
initLock.lock();
try {
this.session = new EdgeGrpcSession(
this, ctx, outputStream, downlinkMessageMapper, uplinkMessageDispatcher,
sessionOpenListener, sessionCloseListener,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
return session.initInputStream();
} finally {
initLock.unlock();
}
}
@Override
public EdgeSessionState getState() {
if (session == null) {
return null;
}
return session.getState();
}
@Override
public void onConfigurationUpdate(Edge edge) {
EdgeSessionState state = getState();
if (state == null) {
log.warn("Session state is null, skipping configuration update for edge [{}]", edge.getId());
return;
}
CustomerId stateCustomerId = state.getEdge().getCustomerId();
state.setEdge(edge);
if (stateCustomerId != null && !stateCustomerId.equals(edge.getCustomerId())) {
// do not send edge configuration message on customer update
// message send by separate flow from assign_to or unassing_from customer
return;
}
EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder()
.setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)).build();
ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder()
.setEdgeUpdateMsg(edgeConfig)
.build();
session.sendDownlinkMsg(edgeConfigMsg);
}
@Override
public void destroyAndMarkAsZombieIfFailed() {
EdgeSessionState state = getState();
final var finalSession = session;
try (finalSession) {
if (!destroy()) {
if (state != null) {
log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId());
} else {
log.warn("Session destroy failed (state is null). Adding to zombie queue for later cleanup.");
}
zombieSessionCleanupService.add(this);
}
} catch (Exception e) {
if (state != null) {
log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]",
state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId(), e);
} else {
log.warn("Exception during session destroy (state is null)", e);
}
}
}
}

47
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
public interface EdgeGrpcSessionManager {
StreamObserver<RequestMsg> initInputStream(StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService);
EdgeSessionState getState();
void addEventToHighPriorityQueue(EdgeEvent edgeEvent);
void startSyncProcess(boolean fullSync);
void onEdgeConnect();
void onEdgeEventUpdate();
void onConfigurationUpdate(Edge edge);
void onEdgeDisconnect();
void onEdgeRemoval();
void destroyAndMarkAsZombieIfFailed();
boolean destroy();
}

302
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java

@ -0,0 +1,302 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.TbKafkaComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@Slf4j
@TbKafkaComponent
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager {
private static final int NO_INITIAL_DELAY_VALUE = 0;
private final TbCoreQueueFactory tbCoreQueueFactory;
private final TopicService topicService;
private final KafkaAdmin kafkaAdmin;
private final EdgeSessionsHolder sessions;
private final AtomicReference<ScheduledFuture<?>> initMigrationAndProcessingFutureRef = new AtomicReference<>();
private final AtomicReference<ScheduledFuture<?>> highPriorityProcessingFutureRef = new AtomicReference<>();
private final Lock initLock = new ReentrantLock();
private volatile boolean migrationProcessed;
private volatile boolean isHighPriorityProcessing;
@Getter
private QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer;
private PostgresGeneralEdgeEventsDispatcher psqlGeneralEdgeEventsDispatcher;
private ExecutorService consumerExecutor;
@Override
public void onEdgeConnect() {
scheduleMigrationAndProcessing();
}
@Override
public void onEdgeEventUpdate() {}
@Override
public void onEdgeDisconnect() {}
@Override
public void onEdgeRemoval() {
EdgeSessionState state = getState();
String topic = topicService
.buildEdgeEventNotificationsTopicPartitionInfo(state.getTenantId(), state.getEdgeId())
.getTopic();
kafkaAdmin.deleteTopic(topic);
kafkaAdmin.deleteConsumerGroup(topic);
}
@Override
public boolean destroy() {
cancelHighPriorityProcessing();
EdgeSessionState state = getState();
try {
if (consumer != null) {
log.info("[{}][{}] Stopping edge event consumer...", state.getTenantId(), state.getEdgeId());
consumer.stop();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to stop edge event consumer", state.getTenantId(), state.getEdgeId(), e);
return false;
}
consumer = null;
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
consumerExecutor.shutdown();
awaitConsumerTermination();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown edge event consumer executor", state.getTenantId(), state.getEdgeId(), e);
return false;
}
return true;
}
private void scheduleMigrationAndProcessing() {
cancelMigrationAndProcessingInit();
EdgeSessionState state = getSession().getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
if (!sessions.hasByEdgeId(edgeId)) {
log.debug("[{}] Session was removed and edge event consumer must not be started [{}]",
tenantId, edgeId.getId());
return;
}
ScheduledFuture<?> initMigrationAndProcessingTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> {
log.info("[{}] Edge event processing task started for edge [{}]", tenantId, edgeId);
initLock.lock();
try {
processRemainingPostgresEventsIfRequired(edgeId);
if (migrationProcessed) {
initAndLaunchConsumerOrRetry(tenantId, edgeId, state);
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
}
finally {
initLock.unlock();
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
initMigrationAndProcessingFutureRef.set(initMigrationAndProcessingTask);
}
private void initAndLaunchConsumerOrRetry(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (initAndLaunchConsumer(tenantId, edgeId, state)) {
launchProcessingOfHighPriorityEvents(tenantId, edgeId);
} else {
scheduleMigrationAndProcessing();
}
}
private void launchProcessingOfHighPriorityEvents(TenantId tenantId, EdgeId edgeId) {
cancelHighPriorityProcessing();
ScheduledFuture<?> highPriorityProcessingTask = ctx.getEdgeEventProcessingExecutorService().scheduleAtFixedRate(() -> {
try {
isHighPriorityProcessing = true;
session.processHighPriorityEvents();
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
} finally {
isHighPriorityProcessing = false;
}
}, NO_INITIAL_DELAY_VALUE, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
highPriorityProcessingFutureRef.set(highPriorityProcessingTask);
}
private boolean initAndLaunchConsumer(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return false;
}
if (isConsumerInitializationRequired()) {
initConsumerAndExecutor(tenantId, edgeId, state);
}
return true;
}
private void initConsumerAndExecutor(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
try {
consumerExecutor.shutdown();
awaitConsumerTermination();
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edgeId, e);
}
}
this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer"));
this.consumer = QueueConsumerManager.<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>builder()
.name("TB Edge events [" + edgeId + "]")
.msgPackProcessor(this::processMsgs)
.pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval())
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edgeId))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edgeId)
.build();
consumer.subscribe();
consumer.launch();
} catch (Exception e) {
destroy();
log.warn("[{}][{}] Failed to start edge event consumer", state.getSessionId(), edgeId, e);
}
}
private boolean isConsumerInitializationRequired() {
return consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped());
}
private void processMsgs(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer) {
EdgeSessionState state = getSession().getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
log.trace("[{}][{}] starting processing edge events", tenantId, edgeId);
if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return;
}
List<EdgeEvent> edgeEvents = new ArrayList<>();
for (TbProtoQueueMsg<ToEdgeEventNotificationMsg> msg : msgs) {
EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
edgeEvents.add(edgeEvent);
}
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, edgeEvents);
boolean isInterrupted = true;
try {
isInterrupted = session.sendDownlinkMsgsPack(downlinkMsgsPack).get();
if (isInterrupted) {
log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edgeId);
}
} catch (Exception e) {
log.error("[{}][{}] Failed to process downlink messages", tenantId, edgeId, e);
}
if (!isInterrupted) {
consumer.commit();
}
}
private void cancelMigrationAndProcessingInit() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge migration & processing init for edge", state != null ? state.getEdgeId() : "unknown");
cancelFuture(initMigrationAndProcessingFutureRef);
}
private void cancelHighPriorityProcessing() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling high priority processing task for edge", state != null ? state.getEdgeId() : "unknown");
cancelFuture(highPriorityProcessingFutureRef);
}
private void cancelFuture(AtomicReference<? extends Future<?>> futureReference) {
Future<?> f = futureReference.getAndSet(null);
if (f != null && !f.isCancelled() && !f.isDone()) {
f.cancel(true);
}
}
private void processRemainingPostgresEventsIfRequired(EdgeId edgeId) throws Exception {
log.debug("Processing remaining postgres edge events for edge [{}]", edgeId);
if (psqlGeneralEdgeEventsDispatcher == null) {
psqlGeneralEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx);
}
if (migrationProcessed) {
return;
}
Boolean hasMorePostgresEvents = psqlGeneralEdgeEventsDispatcher.processNewEvents().get();
if (Boolean.TRUE.equals(hasMorePostgresEvents)) {
log.info("Migration still in progress for edge [{}]", edgeId.getId());
scheduleMigrationAndProcessing();
} else if (Boolean.FALSE.equals(hasMorePostgresEvents)) {
log.info("Migration completed for edge [{}]", edgeId.getId());
migrationProcessed = true;
}
}
private void awaitConsumerTermination() {
EdgeSessionState state = getState();
try {
if (!consumerExecutor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) {
consumerExecutor.shutdownNow();
}
} catch (InterruptedException ie) {
log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", state.getTenantId(), state.getEdgeId());
Thread.currentThread().interrupt();
}
}
}

183
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java

@ -0,0 +1,183 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@Slf4j
@ConditionalOnExpression("'${queue.type:null}'!='kafka'")
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class PostgresBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager {
private final EdgeSessionsHolder edgeSessions;
private final Lock newEventsLock = new ReentrantLock();
private final AtomicReference<ScheduledFuture<?>> edgeEventCheckFutureRef = new AtomicReference<>();
private PostgresGeneralEdgeEventsDispatcher generalEdgeEventsDispatcher;
private volatile boolean hasNewEvents;
@Override
public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) {
super.addEventToHighPriorityQueue(edgeEvent);
markHasNewEvents();
}
@Override
public void onEdgeConnect() {
markHasNewEvents();
scheduleEdgeEventsCheck();
}
@Override
public void onEdgeEventUpdate() {
markHasNewEvents();
}
@Override
public void onEdgeDisconnect() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
}
@Override
public void onEdgeRemoval() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
}
@Override
public boolean destroy() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
return true;
}
private void scheduleEdgeEventsCheck() {
cancelScheduleEdgeEventsCheck();
EdgeId edgeId = getState().getEdgeId();
TenantId tenantId = getState().getTenantId();
if (!edgeSessions.hasByEdgeId(edgeId)) {
log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]",
tenantId, edgeId.getId());
return;
}
ScheduledFuture<?> edgeEventCheckTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> {
try {
newEventsLock.lock();
try {
if (!hasNewEvents) {
scheduleEdgeEventsCheck();
return;
}
log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId());
hasNewEvents = false;
session.processHighPriorityEvents();
processEdgeEventsWithCallback(tenantId, edgeId);
} finally {
newEventsLock.unlock();
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
edgeEventCheckFutureRef.set(edgeEventCheckTask);
log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId());
}
private void processEdgeEventsWithCallback(TenantId tenantId, EdgeId edgeId) throws Exception {
Futures.addCallback(processEdgeEvents(), new FutureCallback<>() {
@Override
public void onSuccess(Boolean newEventsAdded) {
if (Boolean.TRUE.equals(newEventsAdded)) {
log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId());
hasNewEvents = true;
}
scheduleEdgeEventsCheck();
}
@Override
public void onFailure(Throwable t) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, t);
scheduleEdgeEventsCheck();
}
}, ctx.getGrpcCallbackExecutorService());
}
public ListenableFuture<Boolean> processEdgeEvents() throws Exception {
if (generalEdgeEventsDispatcher == null) {
generalEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx);
}
return generalEdgeEventsDispatcher.processNewEvents();
}
private void cancelScheduleEdgeEventsCheck() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge event check for edge", state != null ? state.getEdgeId() : "unknown");
ScheduledFuture<?> sf = edgeEventCheckFutureRef.getAndSet(null);
if (sf != null && !sf.isCancelled() && !sf.isDone()) {
sf.cancel(true);
}
}
private void markHasNoEvents() {
markHasEvents(false);
}
private void markHasNewEvents() {
markHasEvents(true);
}
private void markHasEvents(boolean newEventsPresent) {
newEventsLock.lock();
try {
if (hasNewEvents != newEventsPresent) {
EdgeSessionState state = getState();
if (state != null) {
log.trace("[{}] set session new events flag to {} [{}]", state.getTenantId(), newEventsPresent, state.getEdgeId());
}
hasNewEvents = newEventsPresent;
}
} finally {
newEventsLock.unlock();
}
}
}

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

@ -230,12 +230,15 @@ security:
# - Widgets loading external resources (images, fonts, scripts)
# - Dashboard embedding via iframes (if frame-ancestors is restrictive)
# Use 'report-only: true' first to test the impact before enforcing.
# Example value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'"
# The default value covers core ThingsBoard functionality including OpenStreetMap, ArcGIS maps,
# solution template previews, WebSocket telemetry, and chart Web Workers.
# NOTE: Google Maps requires adding Google domains to script-src, which is not included by default.
# To add Google Maps support, append to script-src: https://maps.googleapis.com https://maps.gstatic.com
content-security-policy:
# Enable/disable Content-Security-Policy header. Mitigates XSS and data injection attacks
enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}"
# Full CSP directive string
value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:}"
# Full CSP directive string. The default value is a working policy for most ThingsBoard deployments
value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://img.thingsboard.io https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.arcgis.com; font-src 'self' data:; connect-src 'self' ws: wss: https://*.arcgis.com https://*.arcgisonline.com; worker-src 'self' blob:; frame-ancestors 'self'}"
# If true, uses Content-Security-Policy-Report-Only header instead — the browser
# reports violations but does not enforce them. Use for testing before enforcing.
report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}"

12
application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java

@ -32,7 +32,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import java.io.ByteArrayInputStream;
@ -56,7 +55,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
/**
* Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method.
* Tests for Edge gRPC SSL setup using the production {@link GrpcServer#setupSsl} method.
* <p>
* Covers:
* 1. Separate cert and key PEM inputs
@ -68,6 +67,8 @@ import static org.awaitility.Awaitility.await;
*/
class EdgeGrpcSslTest {
private static final int TIMEOUT = 30;
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
@ -170,13 +171,14 @@ class EdgeGrpcSslTest {
Path certOnlyFile = writeTempPem("cert-only", cert);
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null))
.isInstanceOf(IllegalArgumentException.class);
.isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(IllegalArgumentException.class);
}
// --- Server startup using production EdgeGrpcService.setupSsl() ---
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception {
EdgeGrpcService edgeGrpcService = new EdgeGrpcService();
GrpcServer edgeGrpcService = new GrpcServer(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {});
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource);
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource);
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : "");
@ -200,7 +202,7 @@ class EdgeGrpcSslTest {
.build();
channel.getState(true); // trigger connection attempt
await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS)
await().atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(50, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
var state = channel.getState(false);

3
application/src/test/resources/logback-test.xml

@ -18,9 +18,6 @@
<logger name="org.thingsboard.server.controller.AbstractWebTest" level="INFO"/>
<logger name="org.thingsboard.server.service.script" level="INFO"/>
<!-- mute TelemetryEdgeSqlTest that causes a lot of randomly generated errors -->
<logger name="org.thingsboard.server.service.edge.rpc.PostgresEdgeGrpcSession" level="OFF"/>
<!-- LwM2m lifecycle debug for the test scope -->
<logger name="org.thingsboard.server.transport.lwm2m.server.downlink.DefaultLwM2mDownlinkMsgHandler" level="TRACE"/>
<logger name="org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler" level="TRACE"/>

6
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java

@ -30,6 +30,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@Component
@ -88,6 +89,11 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
// no-op
}
@Override
public void addBundleRegisterHandler(BiConsumer<String, SslBundle> handler) {
// no-op
}
};
}

2
common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java

@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class SsrfProtectionValidatorTest {
@ParameterizedTest

62
pom.xml

@ -39,11 +39,8 @@
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<thingsboard.client.version>4.4.0-SNAPSHOT</thingsboard.client.version>
<spring-boot.version>3.4.13</spring-boot.version>
<tomcat.version>10.1.52</tomcat.version> <!-- to fix CVE-2026-24734 and CVE-2025-66614. TODO: remove when fixed in spring-boot-dependencies -->
<jackson.version>2.18.6</jackson.version> <!-- to fix CWE-770. TODO: remove when fixed in spring-boot-dependencies -->
<spring-boot.version>3.5.12</spring-boot.version>
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jedis.version>5.1.5</jedis.version>
<jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused -->
<cassandra.version>4.17.0</cassandra.version>
@ -68,7 +65,7 @@
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.9</tbel.version>
<lombok.version>1.18.40</lombok.version>
<lombok.version>1.18.44</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version>
@ -92,7 +89,6 @@
<lz4.version>1.10.1</lz4.version> <!-- to fix CVE-2025-12183 and CVE-2025-66566 introduced through kafka-clients 3.9.1 TODO: remove when kafka-clients is bumped -->
<bucket4j.version>8.10.1</bucket4j.version>
<antlr.version>3.5.3</antlr.version>
<snakeyaml.version>2.2</snakeyaml.version>
<aws.sdk.version>1.12.701</aws.sdk.version>
<pubsub.client.version>1.128.1</pubsub.client.version>
@ -123,7 +119,6 @@
<perfmark-api.version>0.27.0</perfmark-api.version>
<threetenbp.version>1.7.0</threetenbp.version>
<!-- TEST SCOPE -->
<assertj.version>3.27.7</assertj.version> <!-- to fix CVE-2026-24400 (XXE). TODO: remove when fixed in spring-boot-dependencies -->
<dbunit.version>2.7.3</dbunit.version>
<java-websocket.version>1.5.6</java-websocket.version>
<mock-server.version>5.15.0</mock-server.version>
@ -922,43 +917,6 @@
<dependencyManagement>
<dependencies>
<!-- Temporary Tomcat version override -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of Tomcat version override -->
<!-- Temporary Jackson version override -->
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- End of Jackson version override -->
<!-- Temporary assertj-core version override -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<!-- End of assertj-core version override -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
@ -1248,11 +1206,6 @@
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>
<dependency>
<groupId>antlr</groupId>
<artifactId>antlr</artifactId>
@ -1553,12 +1506,6 @@
<version>${dbunit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
@ -1604,11 +1551,6 @@
<artifactId>bcprov-ext-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<dependency>
<groupId>com.sun.winsw</groupId>
<artifactId>winsw</artifactId>

2
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java

@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfSafeAddressResolverGroupTest")
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
class SsrfSafeAddressResolverGroupTest {
private static NioEventLoopGroup eventLoopGroup;

2
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java

@ -22,6 +22,7 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@ -62,6 +63,7 @@ import static org.mockserver.integration.ClientAndServer.startClientAndServer;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class TbHttpClientTest {
EventLoopGroup eventLoop;

26
ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html

@ -16,16 +16,18 @@
-->
<section [formGroup]="serverFormGroup" class="flex flex-col">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.lwm2m-security-config.mode</mat-label>
<mat-select formControlName="securityMode">
<mat-option *ngFor="let securityType of securityConfigLwM2MTypes" [value]="securityType">
{{ lwm2mSecurityTypeTranslationMap.get(securityType) }}
</mat-option>
@for (securityType of securityConfigLwM2MTypes; track securityType) {
<mat-option [value]="securityType">
{{ lwm2mSecurityTypeTranslationMap.get(securityType) }}
</mat-option>
}
</mat-select>
</mat-form-field>
<div [class.!hidden]="serverFormGroup.get('securityMode').value === securityConfigLwM2MType.NO_SEC">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ 'device.lwm2m-security-config.client-publicKey-or-id' | translate }}</mat-label>
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ publicKeyOrIdTooltipNamesMap.get(serverFormGroup.get('securityMode').value) | translate }}">help</mat-icon>
@ -36,11 +38,11 @@
formControlName="clientPublicKeyOrId"
required>
</textarea>
<mat-error *ngIf="serverFormGroup.get('clientPublicKeyOrId').hasError('required')">
{{ 'device.lwm2m-security-config.client-publicKey-or-id-required' | translate }}
</mat-error>
@if (serverFormGroup.get('clientPublicKeyOrId').hasError('required')) {
<mat-error>{{ 'device.lwm2m-security-config.client-publicKey-or-id-required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ 'device.lwm2m-security-config.client-secret-key' | translate }}</mat-label>
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ clientSecretKeyTooltipNamesMap.get(serverFormGroup.get('securityMode').value) | translate }}">help</mat-icon>
@ -51,9 +53,9 @@
formControlName="clientSecretKey"
required>
</textarea>
<mat-error *ngIf="serverFormGroup.get('clientSecretKey').hasError('required')">
{{ 'device.lwm2m-security-config.client-secret-key-required' | translate }}
</mat-error>
@if (serverFormGroup.get('clientSecretKey').hasError('required')) {
<mat-error>{{ 'device.lwm2m-security-config.client-secret-key-required' | translate }}</mat-error>
}
</mat-form-field>
</div>
</section>

128
ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html

@ -18,66 +18,75 @@
<mat-tab-group [formGroup]="lwm2mConfigFormGroup" dynamicHeight>
<mat-tab label="{{ 'device.lwm2m-security-config.client-tab' | translate }}">
<ng-container formGroupName="client">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.lwm2m-security-config.endpoint</mat-label>
<input matInput type="text" formControlName="endpoint" required>
<mat-error *ngIf="lwm2mConfigFormGroup.get('client.endpoint').hasError('required')">
{{ 'device.lwm2m-security-config.endpoint-required' | translate }}
</mat-error>
@if (lwm2mConfigFormGroup.get('client.endpoint').hasError('required')) {
<mat-error>{{ 'device.lwm2m-security-config.endpoint-required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.lwm2m-security-config.mode</mat-label>
<mat-select formControlName="securityConfigClientMode">
<mat-option *ngFor="let securityConfigClientMode of securityConfigLwM2MTypes"
[value]="securityConfigClientMode">
{{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityConfigClientMode]) }}
</mat-option>
@for (securityConfigClientMode of securityConfigLwM2MTypes; track securityConfigClientMode) {
<mat-option [value]="securityConfigClientMode">
{{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityConfigClientMode]) }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block"
*ngIf="lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.PSK">
<mat-label>{{ 'device.lwm2m-security-config.identity' | translate }}</mat-label>
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device.lwm2m-security-config.identity-tooltip' | translate }}">help</mat-icon>
<input matInput type="text" formControlName="identity" required>
<mat-error *ngIf="lwm2mConfigFormGroup.get('client.identity').hasError('required')">
{{ 'device.lwm2m-security-config.identity-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" *ngIf="lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.RPK ||
lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.PSK">
<mat-label>{{ 'device.lwm2m-security-config.client-key' | translate }}</mat-label>
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ clientKeyTooltipNamesMap.get(lwm2mConfigFormGroup.get('client.securityConfigClientMode').value) | translate }}">help</mat-icon>
<textarea matInput
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cols="1"
formControlName="key"
required>
</textarea>
<mat-error *ngIf="lwm2mConfigFormGroup.get('client.key').hasError('required')">
{{ 'device.lwm2m-security-config.client-key-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" *ngIf="lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.X509">
<mat-label translate>device.lwm2m-security-config.client-public-key</mat-label>
<mat-icon *ngIf="lwm2mConfigFormGroup.get('client.cert').value" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device.lwm2m-security-config.client-public-key-tooltip' | translate }}">help</mat-icon>
<textarea matInput
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cols="1"
formControlName="cert">
</textarea>
<mat-hint translate>device.lwm2m-security-config.client-public-key-hint</mat-hint>
</mat-form-field>
<button *ngIf="deviceId"
mat-raised-button color="primary" type="button"
(click)="rebootDevice(false)"
[disabled]="lwm2mConfigFormGroup.get('client').invalid">
{{ 'device.lwm2m-security-config.client-reboot' | translate }}
</button>
@if (lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.PSK) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ 'device.lwm2m-security-config.identity' | translate }}</mat-label>
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device.lwm2m-security-config.identity-tooltip' | translate }}">help</mat-icon>
<input matInput type="text" formControlName="identity" required>
@if (lwm2mConfigFormGroup.get('client.identity').hasError('required')) {
<mat-error>{{ 'device.lwm2m-security-config.identity-required' | translate }}</mat-error>
}
</mat-form-field>
}
@if (lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.RPK ||
lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.PSK) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ 'device.lwm2m-security-config.client-key' | translate }}</mat-label>
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ clientKeyTooltipNamesMap.get(lwm2mConfigFormGroup.get('client.securityConfigClientMode').value) | translate }}">help</mat-icon>
<textarea matInput
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cols="1"
formControlName="key"
required>
</textarea>
@if (lwm2mConfigFormGroup.get('client.key').hasError('required')) {
<mat-error>{{ 'device.lwm2m-security-config.client-key-required' | translate }}</mat-error>
}
</mat-form-field>
}
@if (lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.X509) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.lwm2m-security-config.client-public-key</mat-label>
@if (lwm2mConfigFormGroup.get('client.cert').value) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device.lwm2m-security-config.client-public-key-tooltip' | translate }}">help</mat-icon>
}
<textarea matInput
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cols="1"
formControlName="cert">
</textarea>
<mat-hint translate>device.lwm2m-security-config.client-public-key-hint</mat-hint>
</mat-form-field>
}
@if (deviceId) {
<button mat-raised-button color="primary" type="button"
(click)="rebootDevice(false)"
[disabled]="lwm2mConfigFormGroup.get('client').invalid">
{{ 'device.lwm2m-security-config.client-reboot' | translate }}
</button>
}
</ng-container>
</mat-tab>
<mat-tab label="{{ 'device.lwm2m-security-config.bootstrap-tab' | translate }}">
@ -109,11 +118,12 @@
</mat-expansion-panel>
</mat-accordion>
</div>
<button *ngIf="deviceId"
mat-raised-button color="primary" style="margin-top: 22px" type="button"
(click)="rebootDevice(true)"
[disabled]="lwm2mConfigFormGroup.get('client').invalid">
{{ 'device.lwm2m-security-config.bootstrap-reboot' | translate }}
</button>
@if (deviceId) {
<button mat-raised-button color="primary" style="margin-top: 22px" type="button"
(click)="rebootDevice(true)"
[disabled]="lwm2mConfigFormGroup.get('client').invalid">
{{ 'device.lwm2m-security-config.bootstrap-reboot' | translate }}
</button>
}
</mat-tab>
</mat-tab-group>

3
ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss

@ -27,4 +27,7 @@
.mat-mdc-tab-body {
padding: 16px 0;
}
.mat-mdc-tab-body-content {
padding-top: 4px;
}
}

141
ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html

@ -16,90 +16,87 @@
-->
<section [formGroup]="deviceCredentialsMqttFormGroup">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.client-id</mat-label>
<input matInput formControlName="clientId">
<button type="button"
matSuffix
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-client-id' | translate }}"
matTooltipPosition="above"
(click)="generate('clientId')"
*ngIf="!deviceCredentialsMqttFormGroup.get('clientId').value && !disabled; else copyClientId">
<mat-icon>autorenew</mat-icon>
</button>
<ng-template #copyClientId>
<tb-copy-button
matSuffix
miniButton="false"
*ngIf="deviceCredentialsMqttFormGroup.get('clientId').value"
[copyText]="deviceCredentialsMqttFormGroup.get('clientId').value"
tooltipText="{{ 'device.copy-client-id' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</ng-template>
<mat-error *ngIf="deviceCredentialsMqttFormGroup.get('clientId').hasError('pattern')">
{{ 'device.client-id-pattern' | translate }}
</mat-error>
<ng-container matSuffix>
@if (!deviceCredentialsMqttFormGroup.get('clientId').value && !disabled) {
<button type="button"
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-client-id' | translate }}"
matTooltipPosition="above"
(click)="generate('clientId')">
<mat-icon>autorenew</mat-icon>
</button>
} @else if (deviceCredentialsMqttFormGroup.get('clientId').value) {
<tb-copy-button
miniButton="false"
[copyText]="deviceCredentialsMqttFormGroup.get('clientId').value"
tooltipText="{{ 'device.copy-client-id' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
}
</ng-container>
@if (deviceCredentialsMqttFormGroup.get('clientId').hasError('pattern')) {
<mat-error>{{ 'device.client-id-pattern' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.user-name</mat-label>
<input matInput formControlName="userName" [required]="!!deviceCredentialsMqttFormGroup.get('password').value">
<button type="button"
matSuffix
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-user-name' | translate }}"
matTooltipPosition="above"
(click)="generate('userName')"
*ngIf="!deviceCredentialsMqttFormGroup.get('userName').value && !disabled; else copyUserName">
<mat-icon>autorenew</mat-icon>
</button>
<ng-template #copyUserName>
<tb-copy-button
matSuffix
miniButton="false"
*ngIf="deviceCredentialsMqttFormGroup.get('userName').value"
[copyText]="deviceCredentialsMqttFormGroup.get('userName').value"
tooltipText="{{ 'device.copy-user-name' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</ng-template>
<mat-error *ngIf="deviceCredentialsMqttFormGroup.get('userName').hasError('required')">
{{ 'device.user-name-required' | translate }}
</mat-error>
<ng-container matSuffix>
@if (!deviceCredentialsMqttFormGroup.get('userName').value && !disabled) {
<button type="button"
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-user-name' | translate }}"
matTooltipPosition="above"
(click)="generate('userName')">
<mat-icon>autorenew</mat-icon>
</button>
} @else if (deviceCredentialsMqttFormGroup.get('userName').value) {
<tb-copy-button
miniButton="false"
[copyText]="deviceCredentialsMqttFormGroup.get('userName').value"
tooltipText="{{ 'device.copy-user-name' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
}
</ng-container>
@if (deviceCredentialsMqttFormGroup.get('userName').hasError('required')) {
<mat-error>{{ 'device.user-name-required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.password</mat-label>
<input matInput formControlName="password"
autocomplete="new-password"
(ngModelChange)="passwordChanged()"
type="password">
<tb-toggle-password matSuffix></tb-toggle-password>
<button type="button"
matSuffix
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-password' | translate }}"
matTooltipPosition="above"
(click)="generate('password')"
*ngIf="!deviceCredentialsMqttFormGroup.get('password').value && !disabled; else copyPassword">
<mat-icon>autorenew</mat-icon>
</button>
<ng-template #copyPassword>
<tb-copy-button
matSuffix
miniButton="false"
*ngIf="deviceCredentialsMqttFormGroup.get('password').value"
[copyText]="deviceCredentialsMqttFormGroup.get('password').value"
tooltipText="{{ 'device.copy-password' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</ng-template>
<ng-container matSuffix>
@if (!deviceCredentialsMqttFormGroup.get('password').value && !disabled) {
<button type="button"
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-password' | translate }}"
matTooltipPosition="above"
(click)="generate('password')">
<mat-icon>autorenew</mat-icon>
</button>
} @else if (deviceCredentialsMqttFormGroup.get('password').value) {
<tb-copy-button
miniButton="false"
[copyText]="deviceCredentialsMqttFormGroup.get('password').value"
tooltipText="{{ 'device.copy-password' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
}
</ng-container>
</mat-form-field>
<tb-error style="margin-top: -12px; display: block;"
[error]="deviceCredentialsMqttFormGroup.hasError('atLeastOne') ?

104
ui-ngx/src/app/modules/home/components/device/device-credentials.component.html

@ -19,71 +19,73 @@
<section [class.!hidden]="!(credentialsTypes?.length > 1)" style="margin-bottom: 16px">
<div class="tb-type-title" translate>device.credentials-type</div>
<tb-toggle-select formControlName="credentialsType" appearance="fill">
<tb-toggle-option *ngFor="let credentialsType of credentialsTypes" [value]="credentialsType">
{{ credentialTypeNamesMap.get(credentialsType) }}
</tb-toggle-option>
@for (credentialsType of credentialsTypes; track credentialsType) {
<tb-toggle-option [value]="credentialsType">
{{ credentialTypeNamesMap.get(credentialsType) }}
</tb-toggle-option>
}
</tb-toggle-select>
</section>
<div [ngSwitch]="deviceCredentialsFormGroup.get('credentialsType').value">
<ng-template [ngSwitchCase]="deviceCredentialsType.ACCESS_TOKEN">
<mat-form-field class="mat-block">
@switch (deviceCredentialsFormGroup.get('credentialsType').value) {
@case (deviceCredentialsType.ACCESS_TOKEN) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.access-token</mat-label>
<input matInput formControlName="credentialsId" required>
<button type="button"
matSuffix
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-access-token' | translate }}"
matTooltipPosition="above"
(click)="generate('credentialsId')"
*ngIf="!deviceCredentialsFormGroup.get('credentialsId').value && !disabled; else copyAccessToken">
<mat-icon>autorenew</mat-icon>
</button>
<ng-template #copyAccessToken>
<ng-container matSuffix>
@if (!deviceCredentialsFormGroup.get('credentialsId').value && !disabled) {
<button type="button"
mat-icon-button
aria-label="Generate"
matTooltip="{{ 'device.generate-access-token' | translate }}"
matTooltipPosition="above"
(click)="generate('credentialsId')">
<mat-icon>autorenew</mat-icon>
</button>
} @else if (deviceCredentialsFormGroup.get('credentialsId').value) {
<tb-copy-button
miniButton="false"
[copyText]="deviceCredentialsFormGroup.get('credentialsId').value"
tooltipText="{{ 'device.copy-access-token' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
}
</ng-container>
@if (deviceCredentialsFormGroup.get('credentialsId').hasError('required')) {
<mat-error>{{ 'device.access-token-required' | translate }}</mat-error>
}
@if (deviceCredentialsFormGroup.get('credentialsId').hasError('pattern')) {
<mat-error>{{ 'device.access-token-invalid' | translate }}</mat-error>
}
</mat-form-field>
}
@case (deviceCredentialsType.X509_CERTIFICATE) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.certificate-pem-format</mat-label>
<textarea matInput formControlName="credentialsValue" cols="15" rows="5" required></textarea>
@if (deviceCredentialsFormGroup.get('credentialsValue').value) {
<tb-copy-button
matSuffix
miniButton="false"
*ngIf="deviceCredentialsFormGroup.get('credentialsId').value"
[copyText]="deviceCredentialsFormGroup.get('credentialsId').value"
tooltipText="{{ 'device.copy-access-token' | translate }}"
[copyText]="deviceCredentialsFormGroup.get('credentialsValue').value"
tooltipText="{{ 'device.copy-certificate' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</ng-template>
<mat-error *ngIf="deviceCredentialsFormGroup.get('credentialsId').hasError('required')">
{{ 'device.access-token-required' | translate }}
</mat-error>
<mat-error *ngIf="deviceCredentialsFormGroup.get('credentialsId').hasError('pattern')">
{{ 'device.access-token-invalid' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="deviceCredentialsType.X509_CERTIFICATE">
<mat-form-field class="mat-block">
<mat-label translate>device.certificate-pem-format</mat-label>
<textarea matInput formControlName="credentialsValue" cols="15" rows="5" required></textarea>
<tb-copy-button
matSuffix
miniButton="false"
*ngIf="deviceCredentialsFormGroup.get('credentialsValue').value"
[copyText]="deviceCredentialsFormGroup.get('credentialsValue').value"
tooltipText="{{ 'device.copy-certificate' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
<mat-error *ngIf="deviceCredentialsFormGroup.get('credentialsValue').hasError('required')">
{{ 'device.certificate-pem-format-required' | translate }}
</mat-error>
}
@if (deviceCredentialsFormGroup.get('credentialsValue').hasError('required')) {
<mat-error>{{ 'device.certificate-pem-format-required' | translate }}</mat-error>
}
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="deviceCredentialsType.MQTT_BASIC">
}
@case (deviceCredentialsType.MQTT_BASIC) {
<tb-device-credentials-mqtt-basic formControlName="credentialsValue">
</tb-device-credentials-mqtt-basic>
</ng-template>
<ng-template [ngSwitchCase]="deviceCredentialsType.LWM2M_CREDENTIALS">
}
@case (deviceCredentialsType.LWM2M_CREDENTIALS) {
<tb-device-credentials-lwm2m formControlName="credentialsValue"
[deviceId]="deviceId">
</tb-device-credentials-lwm2m>
</ng-template>
</div>
}
}
</section>

109
ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html

@ -25,9 +25,11 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="(isLoading$ | async) === false"></div>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate"></mat-progress-bar>
} @else {
<div style="height: 4px;"></div>
}
<div mat-dialog-content>
<mat-horizontal-stepper [linear]="true"
#addDeviceProfileStepper
@ -37,54 +39,60 @@
<mat-step [stepControl]="deviceProfileDetailsFormGroup">
<form [formGroup]="deviceProfileDetailsFormGroup">
<ng-template matStepLabel>{{ 'device-profile.device-profile-details' | translate }}</ng-template>
<fieldset [disabled]="isLoading$ | async">
<mat-form-field class="mat-block">
<fieldset [disabled]="isLoading$ | async" class="flex flex-col gap-2">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.name</mat-label>
<input matInput formControlName="name" required/>
<mat-error *ngIf="deviceProfileDetailsFormGroup.get('name').hasError('required')">
{{ 'device-profile.name-required' | translate }}
</mat-error>
<mat-error *ngIf="deviceProfileDetailsFormGroup.get('name').hasError('maxlength')">
{{ 'device-profile.name-max-length' | translate }}
</mat-error>
@if (deviceProfileDetailsFormGroup.get('name').hasError('required')) {
<mat-error>{{ 'device-profile.name-required' | translate }}</mat-error>
}
@if (deviceProfileDetailsFormGroup.get('name').hasError('maxlength')) {
<mat-error>{{ 'device-profile.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-rule-chain-autocomplete
labelText="device-profile.default-rule-chain"
appearance="outline"
formControlName="defaultRuleChainId">
</tb-rule-chain-autocomplete>
<tb-dashboard-autocomplete
label="{{'device-profile.mobile-dashboard' | translate}}"
appearance="outline"
formControlName="defaultDashboardId">
<span tb-hint>{{'device-profile.mobile-dashboard-hint' | translate}}</span>
</tb-dashboard-autocomplete>
<tb-queue-autocomplete
[queueType]="serviceType"
appearance="outline"
formControlName="defaultQueueName">
</tb-queue-autocomplete>
<tb-rule-chain-autocomplete
labelText="device-profile.default-edge-rule-chain"
appearance="outline"
formControlName="defaultEdgeRuleChainId"
[ruleChainType]="edgeRuleChainType">
<span tb-hint>{{'device-profile.default-edge-rule-chain-hint' | translate}}</span>
</tb-rule-chain-autocomplete>
<mat-form-field class="mat-block !hidden">
<mat-form-field class="mat-block !hidden" appearance="outline">
<mat-label translate>device-profile.type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of deviceProfileTypes" [value]="type">
{{deviceProfileTypeTranslations.get(type) | translate}}
</mat-option>
@for (type of deviceProfileTypes; track type) {
<mat-option [value]="type">
{{ deviceProfileTypeTranslations.get(type) | translate }}
</mat-option>
}
</mat-select>
<mat-error *ngIf="deviceProfileDetailsFormGroup.get('type').hasError('required')">
{{ 'device-profile.type-required' | translate }}
</mat-error>
@if (deviceProfileDetailsFormGroup.get('type').hasError('required')) {
<mat-error>{{ 'device-profile.type-required' | translate }}</mat-error>
}
</mat-form-field>
<tb-gallery-image-input class="flex-1"
label="{{'device-profile.image' | translate}}"
formControlName="image">
</tb-gallery-image-input>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.description</mat-label>
<textarea matInput formControlName="description" rows="2"></textarea>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>
</fieldset>
</form>
@ -92,24 +100,30 @@
<mat-step [stepControl]="transportConfigFormGroup" [optional]="true">
<form [formGroup]="transportConfigFormGroup" style="padding-bottom: 16px;">
<ng-template matStepLabel>{{ 'device-profile.transport-configuration' | translate }}</ng-template>
<mat-form-field class="mat-block">
<mat-label translate>device-profile.transport-type</mat-label>
<mat-select formControlName="transportType" required>
<mat-option *ngFor="let type of deviceTransportTypes" [value]="type">
{{deviceTransportTypeTranslations.get(type) | translate}}
</mat-option>
</mat-select>
<mat-hint *ngIf="transportConfigFormGroup.get('transportType').value">
{{deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate}}
</mat-hint>
<mat-error *ngIf="transportConfigFormGroup.get('transportType').hasError('required')">
{{ 'device-profile.transport-type-required' | translate }}
</mat-error>
</mat-form-field>
<tb-device-profile-transport-configuration
formControlName="transportConfiguration"
isAdd="true">
</tb-device-profile-transport-configuration>
<fieldset class="flex flex-col gap-2">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.transport-type</mat-label>
<mat-select formControlName="transportType" required>
@for (type of deviceTransportTypes; track type) {
<mat-option [value]="type">
{{ deviceTransportTypeTranslations.get(type) | translate }}
</mat-option>
}
</mat-select>
@if (transportConfigFormGroup.get('transportType').value) {
<mat-hint>
{{ deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate }}
</mat-hint>
}
@if (transportConfigFormGroup.get('transportType').hasError('required')) {
<mat-error>{{ 'device-profile.transport-type-required' | translate }}</mat-error>
}
</mat-form-field>
<tb-device-profile-transport-configuration
formControlName="transportConfiguration"
isAdd="true">
</tb-device-profile-transport-configuration>
</fieldset>
</form>
</mat-step>
<mat-step [stepControl]="provisionConfigFormGroup" [optional]="true">
@ -124,15 +138,18 @@
</div>
<div mat-dialog-actions style="padding: 0">
<div class="dialog-actions-row flex flex-1 flex-row items-center justify-end">
<button mat-stroked-button *ngIf="selectedIndex > 0"
[disabled]="(isLoading$ | async)"
(click)="previousStep()">{{ 'action.back' | translate }}</button>
@if (selectedIndex > 0) {
<button mat-stroked-button
[disabled]="(isLoading$ | async)"
(click)="previousStep()">{{ 'action.back' | translate }}</button>
}
<span class="flex-1"></span>
<button mat-stroked-button
color="primary"
*ngIf="showNext"
[disabled]="(isLoading$ | async)"
(click)="nextStep()">{{ 'action.next-with-label' | translate:{label: (getFormLabel(selectedIndex+1) | translate)} }}</button>
@if (showNext) {
<button mat-stroked-button
color="primary"
[disabled]="(isLoading$ | async)"
(click)="nextStep()">{{ 'action.next-with-label' | translate:{label: (getFormLabel(selectedIndex+1) | translate)} }}</button>
}
</div>
<mat-divider style="width: 100%"></mat-divider>
<div class="dialog-actions-row flex flex-1 flex-row items-center justify-end gap-2">

1
ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss

@ -52,6 +52,7 @@
width: 100%;;
overflow-y: auto;
scrollbar-gutter: stable;
padding-top: 4px;
@media #{$mat-gt-sm} {
min-width: 500px;
}

2
ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field [formGroup]="selectAssetProfileFormGroup" class="mat-block" subscriptSizing="{{ subscriptSizing }}">
<mat-form-field [formGroup]="selectAssetProfileFormGroup" class="mat-block" [subscriptSizing]="subscriptSizing" [appearance]="appearance">
<mat-label>{{ 'asset-profile.asset-profile' | translate }}</mat-label>
<input matInput type="text"
#assetProfileInput

5
ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts

@ -44,7 +44,7 @@ import { AssetProfileId } from '@shared/models/id/asset-profile-id';
import { AssetProfile, AssetProfileInfo } from '@shared/models/asset.models';
import { AssetProfileService } from '@core/http/asset-profile.service';
import { AssetProfileDialogComponent, AssetProfileDialogData } from './asset-profile-dialog.component';
import { SubscriptSizing } from '@angular/material/form-field';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { coerceBoolean } from '@shared/decorators/coercion';
import { AuthUser } from '@shared/models/user.model';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
@ -98,6 +98,9 @@ export class AssetProfileAutocompleteComponent implements ControlValueAccessor,
@Input()
hint: string;
@Input()
appearance: MatFormFieldAppearance = 'fill';
@Output()
assetProfileUpdated = new EventEmitter<AssetProfileId>();

90
ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html

@ -15,70 +15,76 @@
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'export')"
[class.!hidden]="isEdit">
{{'asset-profile.export' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'setDefault')"
[class.!hidden]="isEdit || entity?.default">
{{'asset-profile.set-default' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{'asset-profile.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onAssetProfileIdCopied($event)"
[cbContent]="entity?.id?.id"
@if (!standalone) {
<div class="tb-details-buttons xs:flex xs:flex-col">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'export')"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>asset-profile.copyId</span>
{{'asset-profile.export' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'setDefault')"
[class.!hidden]="isEdit || entity?.default">
{{'asset-profile.set-default' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{'asset-profile.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onAssetProfileIdCopied($event)"
[cbContent]="entity?.id?.id"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>asset-profile.copyId</span>
</button>
</div>
</div>
</div>
}
<div [class.mat-padding]="!standalone" class="flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;" class="flex flex-col gap-2">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>asset-profile.name</mat-label>
<input matInput formControlName="name" required/>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'asset-profile.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'asset-profile.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'asset-profile.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'asset-profile.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-rule-chain-autocomplete
labelText="asset-profile.default-rule-chain"
appearance="outline"
formControlName="defaultRuleChainId">
</tb-rule-chain-autocomplete>
<tb-dashboard-autocomplete
label="{{'asset-profile.mobile-dashboard' | translate}}"
appearance="outline"
formControlName="defaultDashboardId">
<span tb-hint>{{'asset-profile.mobile-dashboard-hint' | translate}}</span>
</tb-dashboard-autocomplete>
<tb-queue-autocomplete
[queueType]="serviceType"
appearance="outline"
formControlName="defaultQueueName">
</tb-queue-autocomplete>
<tb-rule-chain-autocomplete
labelText="asset-profile.default-edge-rule-chain"
appearance="outline"
formControlName="defaultEdgeRuleChainId"
[ruleChainType]="edgeRuleChainType">
<span tb-hint>{{'asset-profile.default-edge-rule-chain-hint' | translate}}</span>
@ -87,7 +93,7 @@
label="{{'asset-profile.image' | translate}}"
formControlName="image">
</tb-gallery-image-input>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>asset-profile.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

126
ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html

@ -16,25 +16,27 @@
-->
<div [formGroup]="provisionConfigurationFormGroup">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.provision-strategy</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of deviceProvisionTypes" [value]="type">
{{deviceProvisionTypeTranslateMap.get(type) | translate}}
</mat-option>
@for (type of deviceProvisionTypes; track type) {
<mat-option [value]="type">
{{ getDeviceProvisionType(type) | translate }}
</mat-option>
}
</mat-select>
<mat-error *ngIf="provisionConfigurationFormGroup.get('type').hasError('required')">
{{ 'device-profile.provision-strategy-required' | translate }}
</mat-error>
@if (provisionConfigurationFormGroup.get('type').hasError('required')) {
<mat-error>{{ 'device-profile.provision-strategy-required' | translate }}</mat-error>
}
</mat-form-field>
<div [ngSwitch]="provisionConfigurationFormGroup.get('type').value">
<ng-template [ngSwitchCase]="deviceProvisionType.ALLOW_CREATE_NEW_DEVICES">
@switch (provisionConfigurationFormGroup.get('type').value) {
@case (deviceProvisionType.ALLOW_CREATE_NEW_DEVICES) {
<ng-container *ngTemplateOutlet="default"></ng-container>
</ng-template>
<ng-template [ngSwitchCase]="deviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES">
}
@case (deviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES) {
<ng-container *ngTemplateOutlet="default"></ng-container>
</ng-template>
<ng-template [ngSwitchCase]="deviceProvisionType.X509_CERTIFICATE_CHAIN">
}
@case (deviceProvisionType.X509_CERTIFICATE_CHAIN) {
<div class="tb-hint flex flex-1 items-center justify-start">
<span [innerHTML]="'device-profile.provision-strategy-x509.certificate-chain-hint' | translate"></span>
<span tb-help-popup="device-profile/x509-chain-hint"
@ -47,60 +49,60 @@
{{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }}
</mat-slide-toggle>
<div class="tb-hint" style="padding:0 40px 16px" [innerHTML]="'device-profile.provision-strategy-x509.allow-create-new-devices-hint' | translate"></div>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.provision-strategy-x509.certificate-value</mat-label>
<textarea matInput formControlName="certificateValue" cols="15" rows="5" required></textarea>
<mat-error *ngIf="provisionConfigurationFormGroup.get('certificateValue').hasError('required')">
{{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }}
</mat-error>
<textarea matInput formControlName="certificateValue" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1" required></textarea>
@if (provisionConfigurationFormGroup.get('certificateValue').hasError('required')) {
<mat-error>{{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.provision-strategy-x509.cn-regex-variable</mat-label>
<input matInput type="text" formControlName="certificateRegExPattern" required>
<span matSuffix
tb-help-popup="device-profile/x509-chain-regex-examples"
tb-help-popup-placement="top"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '820px'}"></span>
<mat-error *ngIf="provisionConfigurationFormGroup.get('certificateRegExPattern').hasError('required')">
{{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }}
</mat-error>
tb-help-popup="device-profile/x509-chain-regex-examples"
tb-help-popup-placement="top"
trigger-style="letter-spacing:0.25px"
[tb-help-popup-style]="{maxWidth: '820px'}"></span>
@if (provisionConfigurationFormGroup.get('certificateRegExPattern').hasError('required')) {
<mat-error>{{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }}</mat-error>
}
<mat-hint translate>device-profile.provision-strategy-x509.cn-regex-variable-hint</mat-hint>
</mat-form-field>
</ng-template>
<ng-template #default>
<section class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1">
<mat-label translate>device-profile.provision-device-key</mat-label>
<input matInput formControlName="provisionDeviceKey" required/>
<button matSuffix mat-icon-button
ngxClipboard
[cbContent]="provisionConfigurationFormGroup.get('provisionDeviceKey').value"
(cbOnSuccess)="onProvisionCopied(true)"
matTooltip="{{ 'device-profile.copy-provision-key' | translate }}"
matTooltipPosition="above">
<mat-icon svgIcon="mdi:clipboard-arrow-left" style="font-size: 20px;"></mat-icon>
</button>
<mat-error *ngIf="provisionConfigurationFormGroup.get('provisionDeviceKey').hasError('required')">
{{ 'device-profile.provision-device-key-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>device-profile.provision-device-secret</mat-label>
<input matInput formControlName="provisionDeviceSecret" required/>
<button matSuffix mat-icon-button
ngxClipboard
[cbContent]="provisionConfigurationFormGroup.get('provisionDeviceSecret').value"
(cbOnSuccess)="onProvisionCopied(false)"
matTooltip="{{ 'device-profile.copy-provision-secret' | translate }}"
matTooltipPosition="above">
<mat-icon svgIcon="mdi:clipboard-arrow-left" style="font-size: 20px;"></mat-icon>
</button>
<mat-error *ngIf="provisionConfigurationFormGroup.get('provisionDeviceSecret').hasError('required')">
{{ 'device-profile.provision-device-secret-required' | translate }}
</mat-error>
</mat-form-field>
</section>
</ng-template>
</div>
}
}
</div>
<ng-template #default>
<section class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>device-profile.provision-device-key</mat-label>
<input matInput formControlName="provisionDeviceKey" required/>
<button matSuffix mat-icon-button
ngxClipboard
[cbContent]="provisionConfigurationFormGroup.get('provisionDeviceKey').value"
(cbOnSuccess)="onProvisionCopied(true)"
matTooltip="{{ 'device-profile.copy-provision-key' | translate }}"
matTooltipPosition="above">
<mat-icon svgIcon="mdi:clipboard-arrow-left" style="font-size: 20px;"></mat-icon>
</button>
@if (provisionConfigurationFormGroup.get('provisionDeviceKey').hasError('required')) {
<mat-error>{{ 'device-profile.provision-device-key-required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>device-profile.provision-device-secret</mat-label>
<input matInput formControlName="provisionDeviceSecret" required/>
<button matSuffix mat-icon-button
ngxClipboard
[cbContent]="provisionConfigurationFormGroup.get('provisionDeviceSecret').value"
(cbOnSuccess)="onProvisionCopied(false)"
matTooltip="{{ 'device-profile.copy-provision-secret' | translate }}"
matTooltipPosition="above">
<mat-icon svgIcon="mdi:clipboard-arrow-left" style="font-size: 20px;"></mat-icon>
</button>
@if (provisionConfigurationFormGroup.get('provisionDeviceSecret').hasError('required')) {
<mat-error>{{ 'device-profile.provision-device-secret-required' | translate }}</mat-error>
}
</mat-form-field>
</section>
</ng-template>

4
ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts

@ -213,4 +213,8 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu
this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false});
}
}
getDeviceProvisionType(type: string): string {
return this.deviceProvisionTypeTranslateMap.get(type as DeviceProvisionType);
}
}

248
ui-ngx/src/app/modules/home/components/profile/device-profile.component.html

@ -15,79 +15,90 @@
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'export')"
[class.!hidden]="isEdit">
{{'device-profile.export' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'setDefault')"
[class.!hidden]="isEdit || entity?.default">
{{'device-profile.set-default' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{'device-profile.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onDeviceProfileIdCopied($event)"
[cbContent]="entity?.id?.id"
@if (!standalone) {
<div class="tb-details-buttons xs:flex xs:flex-col">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'export')"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>device-profile.copyId</span>
{{'device-profile.export' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'setDefault')"
[class.!hidden]="isEdit || entity?.default">
{{'device-profile.set-default' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{'device-profile.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onDeviceProfileIdCopied($event)"
[cbContent]="entity?.id?.id"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>device-profile.copyId</span>
</button>
</div>
</div>
</div>
}
<div [class.mat-padding]="!standalone" class="flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;" class="flex flex-col gap-2">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.name</mat-label>
<input matInput formControlName="name" required/>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'device-profile.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'device-profile.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'device-profile.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'device-profile.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-rule-chain-autocomplete
labelText="device-profile.default-rule-chain"
appearance="outline"
formControlName="defaultRuleChainId">
</tb-rule-chain-autocomplete>
<tb-dashboard-autocomplete
label="{{'device-profile.mobile-dashboard' | translate}}"
appearance="outline"
formControlName="defaultDashboardId">
<span tb-hint>{{'device-profile.mobile-dashboard-hint' | translate}}</span>
@if (!entityForm.get('defaultDashboardId').disabled) {
<span tb-hint>{{'device-profile.mobile-dashboard-hint' | translate}}</span>
}
</tb-dashboard-autocomplete>
<tb-queue-autocomplete
[queueType]="serviceType"
appearance="outline"
formControlName="defaultQueueName">
</tb-queue-autocomplete>
<tb-rule-chain-autocomplete
labelText="device-profile.default-edge-rule-chain"
appearance="outline"
formControlName="defaultEdgeRuleChainId"
[ruleChainType]="edgeRuleChainType">
<span tb-hint>{{'device-profile.default-edge-rule-chain-hint' | translate}}</span>
@if (!entityForm.get('defaultEdgeRuleChainId').disabled) {
<span tb-hint>{{'device-profile.default-edge-rule-chain-hint' | translate}}</span>
}
</tb-rule-chain-autocomplete>
<tb-ota-package-autocomplete
[useFullEntityId]="true"
[showDetailsPageLink]="true"
[type]="otaUpdateType.FIRMWARE"
[deviceProfileId]="deviceProfileId?.id"
appearance="outline"
formControlName="firmwareId">
</tb-ota-package-autocomplete>
<tb-ota-package-autocomplete
@ -95,96 +106,107 @@
[showDetailsPageLink]="true"
[type]="otaUpdateType.SOFTWARE"
[deviceProfileId]="deviceProfileId?.id"
appearance="outline"
formControlName="softwareId">
</tb-ota-package-autocomplete>
<mat-form-field class="mat-block !hidden">
<mat-form-field class="mat-block !hidden" appearance="outline">
<mat-label translate>device-profile.type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of deviceProfileTypes" [value]="type">
{{deviceProfileTypeTranslations.get(type) | translate}}
</mat-option>
@for (type of deviceProfileTypes; track type) {
<mat-option [value]="type">
{{ deviceProfileTypeTranslations.get(type) | translate }}
</mat-option>
}
</mat-select>
<mat-error *ngIf="entityForm.get('type').hasError('required')">
{{ 'device-profile.type-required' | translate }}
</mat-error>
@if (entityForm.get('type').hasError('required')) {
<mat-error>{{ 'device-profile.type-required' | translate }}</mat-error>
}
</mat-form-field>
<tb-gallery-image-input class="flex-1"
label="{{'device-profile.image' | translate}}"
formControlName="image">
</tb-gallery-image-input>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>
<mat-form-field *ngIf="standalone" class="mat-block">
<mat-label translate>device-profile.transport-type</mat-label>
<mat-select formControlName="transportType" required>
<mat-option *ngFor="let type of deviceTransportTypes" [value]="type">
{{deviceTransportTypeTranslations.get(type) | translate}}
</mat-option>
</mat-select>
<mat-error *ngIf="entityForm.get('transportType').hasError('required')">
{{ 'device-profile.transport-type-required' | translate }}
</mat-error>
</mat-form-field>
<div formGroupName="profileData" *ngIf="standalone" style="padding-bottom: 16px;">
<mat-accordion multi="true">
<mat-expansion-panel *ngIf="displayProfileConfiguration" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>device-profile.profile-configuration</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-configuration
formControlName="configuration">
</tb-device-profile-configuration>
</ng-template>
</mat-expansion-panel>
<mat-expansion-panel *ngIf="displayTransportConfiguration" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>device-profile.transport-configuration</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-transport-configuration
formControlName="transportConfiguration"
[isAdd] = "isTransportTypeChanged">
</tb-device-profile-transport-configuration>
</ng-template>
</mat-expansion-panel>
@if (entityForm.get('profileData.alarms').value?.length) {
<mat-expansion-panel [expanded]="false">
@if (standalone) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.transport-type</mat-label>
<mat-select formControlName="transportType" required>
@for (type of deviceTransportTypes; track type) {
<mat-option [value]="type">
{{ deviceTransportTypeTranslations.get(type) | translate }}
</mat-option>
}
</mat-select>
@if (entityForm.get('transportType').hasError('required')) {
<mat-error>{{ 'device-profile.transport-type-required' | translate }}</mat-error>
}
</mat-form-field>
<div formGroupName="profileData" style="padding-bottom: 16px;">
<mat-accordion multi="true">
@if (displayProfileConfiguration) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>device-profile.profile-configuration</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-configuration
formControlName="configuration">
</tb-device-profile-configuration>
</ng-template>
</mat-expansion-panel>
}
@if (displayTransportConfiguration) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>device-profile.transport-configuration</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-transport-configuration
formControlName="transportConfiguration"
[isAdd]="isTransportTypeChanged">
</tb-device-profile-transport-configuration>
</ng-template>
</mat-expansion-panel>
}
@if (entityForm.get('profileData.alarms').value?.length) {
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
<div>{{'device-profile.alarm-rules-with-count' | translate:
{count: entityForm.get('profileData.alarms').value ?
entityForm.get('profileData.alarms').value.length : 0} }}</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-alarms
formControlName="alarms"
[deviceProfileId]="deviceProfileId">
</tb-device-profile-alarms>
</ng-template>
</mat-expansion-panel>
}
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div>{{'device-profile.alarm-rules-with-count' | translate:
{count: entityForm.get('profileData.alarms').value ?
entityForm.get('profileData.alarms').value.length : 0} }}</div>
<div translate>device-profile.device-provisioning</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-alarms
formControlName="alarms"
[deviceProfileId]="deviceProfileId">
</tb-device-profile-alarms>
<tb-device-profile-provision-configuration
formControlName="provisionConfiguration">
</tb-device-profile-provision-configuration>
</ng-template>
</mat-expansion-panel>
}
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>device-profile.device-provisioning</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-device-profile-provision-configuration
formControlName="provisionConfiguration">
</tb-device-profile-provision-configuration>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</div>
</mat-accordion>
</div>
}
</fieldset>
</form>
</div>

138
ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html

@ -20,81 +20,81 @@
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.coap-device-type</legend>
<div class="flex flex-col gap-2">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-select formControlName="coapDeviceType" required>
<mat-option *ngFor="let type of coapTransportDeviceTypes" [value]="type">
{{ coapTransportDeviceTypeTranslations.get(type) | translate}}
</mat-option>
@for (type of coapTransportDeviceTypes; track type) {
<mat-option [value]="type">
{{ coapTransportDeviceTypeTranslations.get(type) | translate }}
</mat-option>
}
</mat-select>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.coapDeviceType').hasError('required')">
{{ 'device-profile.coap-device-type-required' | translate }}
</mat-error>
@if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.coapDeviceType').hasError('required')) {
<mat-error>{{ 'device-profile.coap-device-type-required' | translate }}</mat-error>
}
</mat-form-field>
</div>
</fieldset>
<div *ngIf="coapDeviceTypeDefault" formGroupName="transportPayloadTypeConfiguration">
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.coap-device-payload-type</legend>
<div class="flex flex-col gap-2">
<mat-form-field class="mat-block">
<mat-select formControlName="transportPayloadType" required>
<mat-option *ngFor="let type of transportPayloadTypes" [value]="type">
{{ transportPayloadTypeTranslations.get(type) | translate}}
</mat-option>
</mat-select>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.transportPayloadType').hasError('required')">
{{ 'device-profile.mqtt-payload-type-required' | translate }}
</mat-error>
</mat-form-field>
<div *ngIf="protoPayloadType" class="flex flex-col">
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')">
{{ 'device-profile.telemetry-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')">
{{ 'device-profile.attributes-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')">
{{ 'device-profile.rpc-request-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')">
{{ 'device-profile.rpc-response-proto-schema-required' | translate}}
</mat-error>
</ng-container>
@if (coapDeviceTypeDefault) {
<div formGroupName="transportPayloadTypeConfiguration">
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.coap-device-payload-type</legend>
<div class="flex flex-col gap-2">
<mat-form-field class="mat-block" appearance="outline">
<mat-select formControlName="transportPayloadType" required>
@for (type of transportPayloadTypes; track type) {
<mat-option [value]="type">
{{ transportPayloadTypeTranslations.get(type) | translate }}
</mat-option>
}
</mat-select>
@if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.transportPayloadType').hasError('required')) {
<mat-error>{{ 'device-profile.mqtt-payload-type-required' | translate }}</mat-error>
}
</mat-form-field>
@if (protoPayloadType) {
<div class="flex flex-col">
<tb-protobuf-content
class="flex-1"
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.telemetry-proto-schema-required' | translate }}</mat-error>
}
<tb-protobuf-content
class="flex-1"
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.attributes-proto-schema-required' | translate }}</mat-error>
}
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.rpc-request-proto-schema-required' | translate }}</mat-error>
}
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.rpc-response-proto-schema-required' | translate }}</mat-error>
}
</div>
}
</div>
</div>
</fieldset>
</div>
</fieldset>
</div>
}
</section>
<section formGroupName="clientSettings">
<fieldset class="fields-group">

67
ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html

@ -16,37 +16,48 @@
-->
<section [formGroup]="parentForm">
<mat-form-field class="mat-block flex-1">
<mat-label *ngIf="isDeviceSetting">{{ "device-profile.power-saving-mode" | translate }}</mat-label>
<mat-form-field class="mat-block flex-1" appearance="outline">
@if (isDeviceSetting) {
<mat-label>{{ "device-profile.power-saving-mode" | translate }}</mat-label>
}
<mat-select formControlName="powerMode">
<mat-option [value]="null" *ngIf="isDeviceSetting">{{ "device-profile.power-saving-mode-type.default" | translate }}</mat-option>
<mat-option *ngFor="let powerMod of powerMods" [value]="powerMod">
{{ powerModeTranslationMap.get(powerMod) | translate}}
</mat-option>
@if (isDeviceSetting) {
<mat-option [value]="null">{{ "device-profile.power-saving-mode-type.default" | translate }}</mat-option>
}
@for (powerMod of powerMods; track powerMod) {
<mat-option [value]="powerMod">
{{ powerModeTranslationMap.get(powerMod) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<section class="mat-block flex-1" *ngIf="parentForm.get('powerMode').value === 'E_DRX'">
<tb-time-unit-select [labelText]="'device-profile.edrx-cycle'"
[requiredText]="'device-profile.edrx-cycle-required'"
[patternText]="'device-profile.edrx-cycle-pattern'"
[minTime]="5120"
[minText]="'device-profile.edrx-cycle-min'"
formControlName="edrxCycle">
</tb-time-unit-select>
<tb-time-unit-select [labelText]="'device-profile.paging-transmission-window'"
[requiredText]="'device-profile.paging-transmission-window-required'"
[patternText]="'device-profile.paging-transmission-window-pattern'"
@if (parentForm.get('powerMode').value === 'E_DRX') {
<section class="mat-block flex-1">
<tb-time-unit-select [labelText]="'device-profile.edrx-cycle'"
[requiredText]="'device-profile.edrx-cycle-required'"
[patternText]="'device-profile.edrx-cycle-pattern'"
[minTime]="5120"
[minText]="'device-profile.edrx-cycle-min'"
appearance="outline"
formControlName="edrxCycle">
</tb-time-unit-select>
<tb-time-unit-select [labelText]="'device-profile.paging-transmission-window'"
[requiredText]="'device-profile.paging-transmission-window-required'"
[patternText]="'device-profile.paging-transmission-window-pattern'"
[minTime]="1280"
[minText]="'device-profile.paging-transmission-window-min'"
appearance="outline"
formControlName="pagingTransmissionWindow">
</tb-time-unit-select>
</section>
} @else if (parentForm.get('powerMode').value === 'PSM') {
<tb-time-unit-select [labelText]="'device-profile.psm-activity-timer'"
[requiredText]="'device-profile.psm-activity-timer-required'"
[patternText]="'device-profile.psm-activity-timer-pattern'"
[minTime]="1280"
[minText]="'device-profile.paging-transmission-window-min'"
formControlName="pagingTransmissionWindow">
[minText]="'device-profile.psm-activity-timer-min'"
appearance="outline"
formControlName="psmActivityTimer">
</tb-time-unit-select>
</section>
<tb-time-unit-select *ngIf="parentForm.get('powerMode').value === 'PSM'"
[labelText]="'device-profile.psm-activity-timer'"
[requiredText]="'device-profile.psm-activity-timer-required'"
[patternText]="'device-profile.psm-activity-timer-pattern'"
[minTime]="1280"
[minText]="'device-profile.psm-activity-timer-min'"
formControlName="psmActivityTimer">
</tb-time-unit-select>
}
</section>

4
ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html

@ -16,7 +16,7 @@
-->
<section [formGroup]="timeUnitSelectFormGroup" class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" [appearance]="appearance">
<mat-label>{{ labelText | translate }}</mat-label>
<input matInput type="number" min="0" formControlName="time" required>
<mat-error *ngIf="timeUnitSelectFormGroup.get('time').hasError('required')">
@ -29,7 +29,7 @@
{{ (minText || patternText) | translate : {min: minTime/1000} }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" [appearance]="appearance">
<mat-label translate>device-profile.condition-duration-time-unit</mat-label>
<mat-select formControlName="unit">
<mat-option *ngFor="let timeUnit of timeUnits" [value]="timeUnit">

4
ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts

@ -37,6 +37,7 @@ import {
timeUnitTranslationMap
} from '@shared/models/time/time.models';
import { isDefinedAndNotNull, isNumber } from '@core/utils';
import { MatFormFieldAppearance } from '@angular/material/form-field';
interface FormGroupModel {
time: number;
@ -105,6 +106,9 @@ export class TimeUnitSelectComponent implements OnInit, OnDestroy, ControlValueA
@Input()
minText: string;
@Input()
appearance: MatFormFieldAppearance = 'fill';
private propagateChange = (v: any) => {
}

38
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html

@ -17,25 +17,31 @@
-->
<div class="flex flex-col">
<mat-accordion multi="true">
<div *ngFor="let serverConfig of serverConfigsFromArray.controls; trackBy: trackByParams; let $index = index;">
<tb-profile-lwm2m-device-config-server
[formControl]="serverConfig"
[isBootstrap]="serverConfig.value.bootstrapServerIs"
(removeServer)="removeServerConfig($event, $index)"
(isTransportWasRunWithBootstrapChange)="updateIsTransportWasRunWithBootstrap($event)">
</tb-profile-lwm2m-device-config-server>
</div>
@for (serverConfig of serverConfigsFromArray.controls; track trackByParams($index)) {
<div>
<tb-profile-lwm2m-device-config-server
[formControl]="serverConfig"
[isBootstrap]="serverConfig.value.bootstrapServerIs"
(removeServer)="removeServerConfig($event, $index)"
(isTransportWasRunWithBootstrapChange)="updateIsTransportWasRunWithBootstrap($event)">
</tb-profile-lwm2m-device-config-server>
</div>
}
</mat-accordion>
<div *ngIf="!serverConfigsFromArray.controls.length" style="margin:32px 0">
@if (!serverConfigsFromArray.controls.length) {
<div style="margin:32px 0">
<span translate
class="tb-prompt flex items-center justify-center">device-profile.lwm2m.no-config-servers</span>
</div>
<div *ngIf="!disabled" style="padding-top: 16px;">
<button mat-raised-button color="primary"
type="button"
(click)="addServerConfig()">
</div>
}
@if (!disabled) {
<div style="padding-top: 16px;">
<button mat-raised-button color="primary"
type="button"
(click)="addServerConfig()">
<span>{{ (isBootstrapServerNotAvailable() ? 'device-profile.lwm2m.add-lwm2m-server-config' :
'device-profile.lwm2m.add-server-config') | translate }}</span>
</button>
</div>
</button>
</div>
}
</div>

9
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts

@ -18,11 +18,12 @@ import { Component, EventEmitter, forwardRef, Input, OnDestroy, OnInit, Output }
import {
AbstractControl,
ControlValueAccessor,
UntypedFormArray,
UntypedFormBuilder, UntypedFormControl,
UntypedFormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR
NG_VALUE_ACCESSOR,
FormControl,
FormArray
} from '@angular/forms';
import { of, Subject } from 'rxjs';
import { ServerSecurityConfig } from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models';
@ -104,8 +105,8 @@ export class Lwm2mBootstrapConfigServersComponent implements OnInit, ControlValu
this.destroy$.complete();
}
get serverConfigsFromArray(): UntypedFormArray {
return this.bootstrapConfigServersFormGroup.get('serverConfigs') as UntypedFormArray;
get serverConfigsFromArray(): FormArray<FormControl> {
return this.bootstrapConfigServersFormGroup.get('serverConfigs') as FormArray<FormControl>;
}
setDisabledState(isDisabled: boolean): void {

234
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html

@ -22,160 +22,180 @@
<div class="flex flex-1 flex-row items-center justify-start">
<div style="min-width:150px">{{ (serverFormGroup.get('bootstrapServerIs').value ?
'device-profile.lwm2m.bootstrap-server' : 'device-profile.lwm2m.lwm2m-server') | translate }}</div>
<div *ngIf="!serverPanel.expanded" style="font-size:14px" class="no-wrap flex flex-row">
<div style="margin-left:32px">{{ ('device-profile.lwm2m.short-id' | translate) + ': ' }}
<span style="font-style: italic">{{ serverFormGroup.get('shortServerId').value ? serverFormGroup.get('shortServerId').value : '' }}</span>
@if (!serverPanel.expanded) {
<div style="font-size:14px" class="no-wrap flex flex-row">
<div style="margin-left:32px">{{ ('device-profile.lwm2m.short-id' | translate) + ': ' }}
<span style="font-style: italic">{{ serverFormGroup.get('shortServerId').value ? serverFormGroup.get('shortServerId').value : '' }}</span>
</div>
<div style="margin-left:32px">{{ ('device-profile.lwm2m.mode' | translate) + ': ' }}
<span style="font-style: italic">{{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[serverFormGroup.get('securityMode').value]) }}</span>
</div>
</div>
<div style="margin-left:32px">{{ ('device-profile.lwm2m.mode' | translate) + ': ' }}
<span style="font-style: italic">{{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[serverFormGroup.get('securityMode').value]) }}</span>
</div>
</div>
}
</div>
</mat-panel-title>
<span class="flex-full"></span>
<button *ngIf="!disabled" mat-icon-button style="min-width: 40px;"
type="button"
(click)="removeServer.emit($event)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
@if (!disabled) {
<button mat-icon-button style="min-width: 40px;"
type="button"
(click)="removeServer.emit($event)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
}
</div>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<section>
<div class="flex flex-row gap-2 xs:flex-col xs:gap-0">
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.mode' | translate }}</mat-label>
<mat-select formControlName="securityMode">
<mat-option *ngFor="let securityMode of securityConfigLwM2MTypes"
[value]="securityMode">
{{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityMode]) }}
</mat-option>
@for (securityMode of securityConfigLwM2MTypes; track securityMode) {
<mat-option [value]="securityMode">
{{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityMode]) }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-1" *ngIf="!isBootstrap">
<mat-label>{{ 'device-profile.lwm2m.short-id' | translate }}</mat-label>
<mat-icon *ngIf="!disabled" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ (isBootstrap ? 'device-profile.lwm2m.short-id-tooltip-bootstrap': 'device-profile.lwm2m.short-id-tooltip') | translate }}">help</mat-icon>
<input matInput type="number" formControlName="shortServerId" required
[min]="isBootstrap ? undefined : shortServerIdMin"
[max]="isBootstrap ? undefined : shortServerIdMax">
<mat-error *ngIf="serverFormGroup.get('shortServerId').hasError('required')">
{{ 'device-profile.lwm2m.short-id-required' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('shortServerId').hasError('pattern')">
{{ (isBootstrap ? 'device-profile.lwm2m.short-id-pattern-bs' : 'device-profile.lwm2m.short-id-pattern') | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('shortServerId').hasError('min') ||
serverFormGroup.get('shortServerId').hasError('max')">
{{ 'device-profile.lwm2m.short-id-range' | translate : {min: shortServerIdMin, max: shortServerIdMax} }}
</mat-error>
</mat-form-field>
@if (!isBootstrap) {
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.short-id' | translate }}</mat-label>
@if (!disabled) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ (isBootstrap ? 'device-profile.lwm2m.short-id-tooltip-bootstrap' : 'device-profile.lwm2m.short-id-tooltip') | translate }}">help</mat-icon>
}
<input matInput type="number" formControlName="shortServerId" required
[min]="isBootstrap ? undefined : shortServerIdMin"
[max]="isBootstrap ? undefined : shortServerIdMax">
@if (serverFormGroup.get('shortServerId').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.short-id-required' | translate }}</mat-error>
}
@if (serverFormGroup.get('shortServerId').hasError('pattern')) {
<mat-error>{{ (isBootstrap ? 'device-profile.lwm2m.short-id-pattern-bs' : 'device-profile.lwm2m.short-id-pattern') | translate }}</mat-error>
}
@if (serverFormGroup.get('shortServerId').hasError('min') ||
serverFormGroup.get('shortServerId').hasError('max')) {
<mat-error>{{ 'device-profile.lwm2m.short-id-range' | translate : {min: shortServerIdMin, max: shortServerIdMax} }}</mat-error>
}
</mat-form-field>
}
</div>
<div class="flex flex-row gap-2 xs:flex-col xs:gap-0">
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.server-host' | translate }}</mat-label>
<input matInput type="text" formControlName="host" required>
<mat-error *ngIf="serverFormGroup.get('host').hasError('required')">
{{ 'device-profile.lwm2m.server-host-required' | translate }}
</mat-error>
@if (serverFormGroup.get('host').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.server-host-required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.server-port' | translate }}</mat-label>
<input matInput type="number" formControlName="port" required min="0" max="65535" readonly>
<mat-error *ngIf="serverFormGroup.get('port').hasError('required')">
{{ 'device-profile.lwm2m.server-port-required' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('port').hasError('pattern')">
{{ 'device-profile.lwm2m.server-port-pattern' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('port').hasError('min') ||
serverFormGroup.get('port').hasError('max')">
{{ 'device-profile.lwm2m.server-port-range' | translate }}
</mat-error>
@if (serverFormGroup.get('port').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.server-port-required' | translate }}</mat-error>
}
@if (serverFormGroup.get('port').hasError('pattern')) {
<mat-error>{{ 'device-profile.lwm2m.server-port-pattern' | translate }}</mat-error>
}
@if (serverFormGroup.get('port').hasError('min') ||
serverFormGroup.get('port').hasError('max')) {
<mat-error>{{ 'device-profile.lwm2m.server-port-range' | translate }}</mat-error>
}
</mat-form-field>
</div>
<div class="flex flex-row gap-2 xs:flex-col xs:gap-0">
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.client-hold-off-time' | translate }}</mat-label>
<mat-icon *ngIf="!disabled" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{'device-profile.lwm2m.client-hold-off-time-tooltip' | translate }}">help</mat-icon>
@if (!disabled) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device-profile.lwm2m.client-hold-off-time-tooltip' | translate }}">help</mat-icon>
}
<input matInput type="number" formControlName="clientHoldOffTime" required min="0">
<mat-error *ngIf="serverFormGroup.get('clientHoldOffTime').hasError('required')">
{{ 'device-profile.lwm2m.client-hold-off-time-required' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('clientHoldOffTime').hasError('min') ||
serverFormGroup.get('clientHoldOffTime').hasError('pattern')">
{{ 'device-profile.lwm2m.client-hold-off-time-pattern' | translate }}
</mat-error>
@if (serverFormGroup.get('clientHoldOffTime').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.client-hold-off-time-required' | translate }}</mat-error>
}
@if (serverFormGroup.get('clientHoldOffTime').hasError('min') ||
serverFormGroup.get('clientHoldOffTime').hasError('pattern')) {
<mat-error>{{ 'device-profile.lwm2m.client-hold-off-time-pattern' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.account-after-timeout' | translate }}</mat-label>
<mat-icon *ngIf="!disabled" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{'device-profile.lwm2m.account-after-timeout-tooltip' | translate }}">help</mat-icon>
@if (!disabled) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device-profile.lwm2m.account-after-timeout-tooltip' | translate }}">help</mat-icon>
}
<input matInput type="number" formControlName="bootstrapServerAccountTimeout" required min="0">
<mat-error *ngIf="serverFormGroup.get('bootstrapServerAccountTimeout').hasError('required')">
{{ 'device-profile.lwm2m.account-after-timeout-required' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('bootstrapServerAccountTimeout').hasError('min') ||
serverFormGroup.get('bootstrapServerAccountTimeout').hasError('pattern')">
{{ 'device-profile.lwm2m.account-after-timeout-pattern' | translate }}
</mat-error>
@if (serverFormGroup.get('bootstrapServerAccountTimeout').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.account-after-timeout-required' | translate }}</mat-error>
}
@if (serverFormGroup.get('bootstrapServerAccountTimeout').hasError('min') ||
serverFormGroup.get('bootstrapServerAccountTimeout').hasError('pattern')) {
<mat-error>{{ 'device-profile.lwm2m.account-after-timeout-pattern' | translate }}</mat-error>
}
</mat-form-field>
</div>
<div *ngIf="serverFormGroup.get('securityMode').value === securityConfigLwM2MType.RPK ||
serverFormGroup.get('securityMode').value === securityConfigLwM2MType.X509">
<mat-form-field class="mat-block">
@if (serverFormGroup.get('securityMode').value === securityConfigLwM2MType.RPK ||
serverFormGroup.get('securityMode').value === securityConfigLwM2MType.X509) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.server-public-key' | translate }}</mat-label>
<mat-icon *ngIf="!disabled" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ publicKeyOrIdTooltipNamesMap.get(serverFormGroup.get('securityMode').value) | translate }}">help</mat-icon>
@if (!disabled) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ publicKeyOrIdTooltipNamesMap.get(serverFormGroup.get('securityMode').value) | translate }}">help</mat-icon>
}
<textarea matInput
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cols="1" required
formControlName="serverPublicKey"
required>
formControlName="serverPublicKey">
</textarea>
<mat-error *ngIf="serverFormGroup.get('serverPublicKey').hasError('required')">
{{ 'device-profile.lwm2m.server-public-key-required' | translate }}
</mat-error>
@if (serverFormGroup.get('serverPublicKey').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.server-public-key-required' | translate }}</mat-error>
}
</mat-form-field>
</div>
}
<div class="flex flex-row gap-2 xs:flex-col xs:gap-0">
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.lifetime' | translate }}</mat-label>
<input matInput type="number" min="0" formControlName="lifetime" required>
<mat-error *ngIf="serverFormGroup.get('lifetime').hasError('required')">
{{ 'device-profile.lwm2m.lifetime-required' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('lifetime').hasError('pattern') ||
serverFormGroup.get('lifetime').hasError('min')">
{{ 'device-profile.lwm2m.lifetime-pattern' | translate }}
</mat-error>
@if (serverFormGroup.get('lifetime').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.lifetime-required' | translate }}</mat-error>
}
@if (serverFormGroup.get('lifetime').hasError('pattern') ||
serverFormGroup.get('lifetime').hasError('min')) {
<mat-error>{{ 'device-profile.lwm2m.lifetime-pattern' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.default-min-period' | translate }}</mat-label>
<mat-icon *ngIf="!disabled" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{'device-profile.lwm2m.default-min-period-tooltip' | translate }}">help</mat-icon>
@if (!disabled) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device-profile.lwm2m.default-min-period-tooltip' | translate }}">help</mat-icon>
}
<input matInput type="number" min="0" formControlName="defaultMinPeriod" required>
<mat-error *ngIf="serverFormGroup.get('defaultMinPeriod').hasError('required')">
{{ 'device-profile.lwm2m.default-min-period-required' | translate }}
</mat-error>
<mat-error *ngIf="serverFormGroup.get('defaultMinPeriod').hasError('pattern') ||
serverFormGroup.get('defaultMinPeriod').hasError('min')">
{{ 'device-profile.lwm2m.default-min-period-pattern' | translate }}
</mat-error>
@if (serverFormGroup.get('defaultMinPeriod').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.default-min-period-required' | translate }}</mat-error>
}
@if (serverFormGroup.get('defaultMinPeriod').hasError('pattern') ||
serverFormGroup.get('defaultMinPeriod').hasError('min')) {
<mat-error>{{ 'device-profile.lwm2m.default-min-period-pattern' | translate }}</mat-error>
}
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.binding' | translate }}</mat-label>
<mat-icon *ngIf="!disabled" class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{'device-profile.lwm2m.binding-tooltip' | translate }}">help</mat-icon>
@if (!disabled) {
<mat-icon class="mat-primary" aria-hidden="false" aria-label="help-icon" matSuffix style="cursor:pointer;"
matTooltip="{{ 'device-profile.lwm2m.binding-tooltip' | translate }}">help</mat-icon>
}
<mat-select formControlName="binding">
<mat-option *ngFor="let bindingMode of bindingModeTypes" [value]="bindingMode">
{{ bindingModeTypeNamesMap.get(bindingMode) | translate }}
</mat-option>
@for (bindingMode of bindingModeTypes; track bindingMode) {
<mat-option [value]="bindingMode">
{{ bindingModeTypeNamesMap.get(bindingMode) | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="notifIfDisabled" color="primary">

82
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html

@ -19,10 +19,11 @@
<mat-tab-group dynamicHeight>
<mat-tab label="{{ 'device-profile.lwm2m.model-tab' | translate }}">
<ng-template matTabContent>
<section [formGroup]="lwm2mDeviceProfileFormGroup">
<section [formGroup]="lwm2mDeviceProfileFormGroup" class="mt-2">
<tb-profile-lwm2m-object-list
(addList)="addObjectsList($event)"
(removeList)="removeObjectsList($event)"
appearance="outline"
formControlName="objectIds">
</tb-profile-lwm2m-object-list>
<mat-checkbox formControlName="initAttrTelAsObsStrategy">
@ -30,18 +31,20 @@
{{ 'device-profile.lwm2m.init-attr-tel-as-obs-strategy' | translate }}
</span>
</mat-checkbox>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device-profile.lwm2m.observe-strategy.observe-strategy</mat-label>
<mat-select formControlName="observeStrategy">
<mat-select-trigger>
{{ observeStrategyMap.get(lwm2mDeviceProfileFormGroup.get('observeStrategy').value)?.name | translate }}
</mat-select-trigger>
<mat-option *ngFor="let strategy of observeStrategyList" [value]="strategy">
{{ observeStrategyMap.get(strategy).name | translate }}
<small style="display: block;">
{{ observeStrategyMap.get(strategy).description | translate }}
</small>
</mat-option>
@for (strategy of observeStrategyList; track strategy) {
<mat-option [value]="strategy">
{{ observeStrategyMap.get(strategy).name | translate }}
<small style="display: block;">
{{ observeStrategyMap.get(strategy).description | translate }}
</small>
</mat-option>
}
</mat-select>
</mat-form-field>
<tb-profile-lwm2m-observe-attr-telemetry
@ -52,17 +55,20 @@
</mat-tab>
<mat-tab label="{{ 'device-profile.lwm2m.bootstrap-tab' | translate }}">
<section [formGroup]="lwm2mDeviceProfileFormGroup">
<div *ngIf="!isTransportWasRunWithBootstrap" style="padding:16px 0 0 8px;" class="flex flex-row items-end justify-start">
<mat-icon color="warn" class="material-icons" style="margin-right:8px;">warning</mat-icon>
<span style="font-weight:500">LwM2M transport was run without bootstrap server</span>
</div>
@if (!isTransportWasRunWithBootstrap) {
<div style="padding:16px 0 0 8px;" class="flex flex-row items-end justify-start">
<mat-icon color="warn" class="material-icons" style="margin-right:8px;">warning</mat-icon>
<span style="font-weight:500">LwM2M transport was run without bootstrap server</span>
</div>
}
<mat-checkbox formControlName="bootstrapServerUpdateEnable" style="margin-left:8px;margin-top:20px">
{{ 'device-profile.lwm2m.include-bootstrap-server' | translate }}
</mat-checkbox>
<section style="padding: 20px 2px">
<tb-profile-lwm2m-bootstrap-config-servers formControlName="bootstrap"
[isBootstrapServerUpdateEnable]="isBootstrapServerUpdateEnable"
[(isTransportWasRunWithBootstrap)]="isTransportWasRunWithBootstrap">
<tb-profile-lwm2m-bootstrap-config-servers
formControlName="bootstrap"
[isBootstrapServerUpdateEnable]="isBootstrapServerUpdateEnable"
[(isTransportWasRunWithBootstrap)]="isTransportWasRunWithBootstrap">
</tb-profile-lwm2m-bootstrap-config-servers>
</section>
</section>
@ -79,7 +85,7 @@
</mat-checkbox>
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.lwm2m.fw-update</legend>
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.fw-update-strategy' | translate }}</mat-label>
<mat-select formControlName="fwUpdateStrategy">
<mat-option [value]=1>{{ 'device-profile.lwm2m.fw-update-strategy-package' | translate }}</mat-option>
@ -87,30 +93,34 @@
<mat-option [value]=3>{{ 'device-profile.lwm2m.fw-update-strategy-data' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block flex-1" *ngIf="lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.fwUpdateStrategy').value === 2">
<mat-label>{{ 'device-profile.lwm2m.fw-update-resource' | translate }}</mat-label>
<input matInput formControlName="fwUpdateResource" required>
<mat-error *ngIf="lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.fwUpdateResource').hasError('required')">
{{ 'device-profile.lwm2m.fw-update-resource-required' | translate }}
</mat-error>
</mat-form-field>
@if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.fwUpdateStrategy').value === 2) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.fw-update-resource' | translate }}</mat-label>
<input matInput formControlName="fwUpdateResource" required>
@if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.fwUpdateResource').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.fw-update-resource-required' | translate }}</mat-error>
}
</mat-form-field>
}
</fieldset>
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.lwm2m.sw-update</legend>
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.sw-update-strategy' | translate }}</mat-label>
<mat-select formControlName="swUpdateStrategy">
<mat-option [value]=1>{{ 'device-profile.lwm2m.sw-update-strategy-package' | translate }}</mat-option>
<mat-option [value]=2>{{ 'device-profile.lwm2m.sw-update-strategy-package-uri' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block flex-1" *ngIf="lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.swUpdateStrategy').value === 2">
<mat-label>{{ 'device-profile.lwm2m.sw-update-resource' | translate }}</mat-label>
<input matInput formControlName="swUpdateResource" required>
<mat-error *ngIf="lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.swUpdateResource').hasError('required')">
{{ 'device-profile.lwm2m.sw-update-resource-required' | translate }}
</mat-error>
</mat-form-field>
@if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.swUpdateStrategy').value === 2) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label>{{ 'device-profile.lwm2m.sw-update-resource' | translate }}</mat-label>
<input matInput formControlName="swUpdateResource" required>
@if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.swUpdateResource').hasError('required')) {
<mat-error>{{ 'device-profile.lwm2m.sw-update-resource-required' | translate }}</mat-error>
}
</mat-form-field>
}
</fieldset>
</fieldset>
<fieldset class="fields-group">
@ -120,11 +130,13 @@
</fieldset>
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.lwm2m.default-object-id</legend>
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-select formControlName="defaultObjectIDVer">
<mat-option *ngFor="let objectIDVer of objectIDVers" [value]="objectIDVer">
{{ objectIDVerTranslationMap.get(objectIDVer) | translate }}
</mat-option>
@for (objectIDVer of objectIDVers; track objectIDVer) {
<mat-option [value]="objectIDVer">
{{ objectIDVerTranslationMap.get(objectIDVer) | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</fieldset>

2
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field [formGroup]="lwm2mListFormGroup" class="mat-block">
<mat-form-field [formGroup]="lwm2mListFormGroup" class="mat-block" [appearance]="appearance">
<mat-label translate>device-profile.lwm2m.object-list</mat-label>
<mat-chip-grid #chipList formControlName="objectsList" [required]="required">
<mat-chip-row

4
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts

@ -35,6 +35,7 @@ import { isDefined, isDefinedAndNotNull, isObject, isString } from '@core/utils'
import { PageLink } from '@shared/models/page/page-link';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatFormFieldAppearance } from '@angular/material/form-field';
@Component({
selector: 'tb-profile-lwm2m-object-list',
@ -74,6 +75,9 @@ export class Lwm2mObjectListComponent implements ControlValueAccessor, OnInit, V
this.updateValidators();
}
@Input()
appearance: MatFormFieldAppearance = 'fill';
@Output()
addList = new EventEmitter<any>();

105
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html

@ -17,57 +17,58 @@
-->
<section [formGroup]="instancesFormGroup">
<mat-accordion multi="true" formArrayName="instances">
<mat-expansion-panel
class="instance-row"
*ngFor="let instances of instancesFormArray.controls; let $index = index; trackBy: trackByParams"
[formGroupName]="$index"
[expanded]="isExpend"
[disabled]="isExpend">
<mat-expansion-panel-header>
<mat-panel-title class="tb-panel flex flex-row">
<div class="flex max-w-30% flex-full items-center justify-start">
<span class="tb-panel-title">{{ 'device-profile.lwm2m.instance' | translate }}</span>&nbsp;#{{instances.get('id').value}}
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox color="warn"
[disabled]="disabled"
[checked]="getChecked(instances, 'attribute')"
(click)="$event.stopPropagation()"
(change)="changeInstanceResourcesCheckBox($event.checked, instances, 'attribute')"
[indeterminate]="getIndeterminate(instances, 'attribute')">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox color="primary"
[disabled]="disabled"
[checked]="getChecked(instances, 'telemetry')"
(click)="$event.stopPropagation()"
(change)="changeInstanceResourcesCheckBox($event.checked, instances, 'telemetry')"
[indeterminate]="getIndeterminate(instances, 'telemetry')">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox color="primary"
[disabled]="disableObserve(instances)"
[checked]="getChecked(instances, 'observe')"
(click)="$event.stopPropagation()"
(change)="changeInstanceResourcesCheckBox($event.checked, instances, 'observe')"
[indeterminate]="getIndeterminate(instances, 'observe')">
</mat-checkbox>
</div>
<span class="flex-1"></span>
<tb-profile-lwm2m-attributes
formControlName="attributes"
[isAttributeTelemetry]="disableObserveInstance(instances)"
[modelName]="getNameInstance(instances.value)">
</tb-profile-lwm2m-attributes>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-profile-lwm2m-observe-attr-telemetry-resource
formControlName="resources">
</tb-profile-lwm2m-observe-attr-telemetry-resource>
</ng-template>
</mat-expansion-panel>
@for (instances of instancesFormArray.controls; track trackByParams($index, instances.value)) {
<mat-expansion-panel
class="instance-row"
[formGroupName]="$index"
[expanded]="isExpend"
[disabled]="isExpend">
<mat-expansion-panel-header>
<mat-panel-title class="tb-panel flex flex-row">
<div class="flex max-w-30% flex-full items-center justify-start">
<span class="tb-panel-title">{{ 'device-profile.lwm2m.instance' | translate }}</span>&nbsp;#{{ instances.get('id').value }}
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox color="warn"
[disabled]="disabled"
[checked]="getChecked(instances, 'attribute')"
(click)="$event.stopPropagation()"
(change)="changeInstanceResourcesCheckBox($event.checked, instances, 'attribute')"
[indeterminate]="getIndeterminate(instances, 'attribute')">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox color="primary"
[disabled]="disabled"
[checked]="getChecked(instances, 'telemetry')"
(click)="$event.stopPropagation()"
(change)="changeInstanceResourcesCheckBox($event.checked, instances, 'telemetry')"
[indeterminate]="getIndeterminate(instances, 'telemetry')">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox color="primary"
[disabled]="disableObserve(instances)"
[checked]="getChecked(instances, 'observe')"
(click)="$event.stopPropagation()"
(change)="changeInstanceResourcesCheckBox($event.checked, instances, 'observe')"
[indeterminate]="getIndeterminate(instances, 'observe')">
</mat-checkbox>
</div>
<span class="flex-1"></span>
<tb-profile-lwm2m-attributes
formControlName="attributes"
[isAttributeTelemetry]="disableObserveInstance(instances)"
[modelName]="getNameInstance(instances.value)">
</tb-profile-lwm2m-attributes>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-profile-lwm2m-observe-attr-telemetry-resource
formControlName="resources">
</tb-profile-lwm2m-observe-attr-telemetry-resource>
</ng-template>
</mat-expansion-panel>
}
</mat-accordion>
</section>

71
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html

@ -34,41 +34,42 @@
</div>
</div>
<mat-divider></mat-divider>
<div formArrayName="resources"
*ngFor="let resourceLwM2M of resourcesFormArray.controls; let $index = index; trackBy: trackByParams">
<div [formGroupName]="$index" class="resource-list flex flex-row items-center justify-start">
<div class="resource-name max-w-30% flex-full">
#{{resourceLwM2M.get('id').value}} <span class="name">{{resourceLwM2M.get('name').value}}</span>
<div formArrayName="resources">
@for (resourceLwM2M of resourcesFormArray.controls; track trackByParams($index, resourceLwM2M.value)) {
<div [formGroupName]="$index" class="resource-list flex flex-row items-center justify-start">
<div class="resource-name max-w-30% flex-full">
#{{ resourceLwM2M.get('id').value }} <span class="name">{{ resourceLwM2M.get('name').value }}</span>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox formControlName="attribute" color="warn">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox formControlName="telemetry" color="primary">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox formControlName="observe" color="primary"
matTooltip="{{ 'device-profile.lwm2m.edit-observe-select' | translate }}"
[matTooltipDisabled]="disabled || !isDisabledObserve($index)"
matTooltipPosition="above">
</mat-checkbox>
</div>
<mat-form-field class="max-w-33% flex-full" appearance="outline">
<input matInput type="text" formControlName="keyName" required>
@if (resourceLwM2M.get('keyName').hasError('required') ||
resourceLwM2M.get('keyName').hasError('pattern')) {
<mat-error>{{ 'device-profile.lwm2m.key-name-required' | translate }}</mat-error>
}
</mat-form-field>
<span class="flex-1"></span>
<tb-profile-lwm2m-attributes
formControlName="attributes"
[isAttributeTelemetry]="isDisabledObserve($index)"
isResource="true"
[modelName]="getNameResourceLwm2m(resourceLwM2M.value)">
</tb-profile-lwm2m-attributes>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox formControlName="attribute" color="warn">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox formControlName="telemetry" color="primary">
</mat-checkbox>
</div>
<div class="flex max-w-10% flex-full items-center justify-center">
<mat-checkbox formControlName="observe" color="primary"
matTooltip="{{ 'device-profile.lwm2m.edit-observe-select' | translate }}"
[matTooltipDisabled]="disabled || !isDisabledObserve($index)"
matTooltipPosition="above">
</mat-checkbox>
</div>
<mat-form-field class="max-w-33% flex-full">
<input matInput type="text" formControlName="keyName" required>
<mat-error *ngIf="resourceLwM2M.get('keyName').hasError('required') ||
resourceLwM2M.get('keyName').hasError('pattern')">
{{ 'device-profile.lwm2m.key-name-required' | translate }}
</mat-error>
</mat-form-field>
<span class="flex-1"></span>
<tb-profile-lwm2m-attributes
formControlName="attributes"
[isAttributeTelemetry]="isDisabledObserve($index)"
isResource="true"
[modelName]="getNameResourceLwm2m(resourceLwM2M.value)">
</tb-profile-lwm2m-attributes>
</div>
}
</div>
</section>

54
ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html

@ -17,31 +17,33 @@
-->
<section [formGroup]="modelsFormGroup">
<mat-accordion multi="true" formArrayName="models">
<mat-expansion-panel
*ngFor="let objectLwM2M of modelsFormArray.controls; let $index = index; trackBy: trackByParams"
[formGroupName]="$index">
<mat-expansion-panel-header >
<mat-panel-title class="flex items-center justify-start">
<span class="tb-panel-title">{{ objectLwM2M.get('name').value}}</span>&nbsp;#{{ objectLwM2M.get('keyId').value}}
<span class="flex-1"></span>
<tb-profile-lwm2m-attributes
formControlName="attributes"
[modelName]="getNameObject(objectLwM2M.value)">
</tb-profile-lwm2m-attributes>
<button type="button"
*ngIf="!disabled && objectLwM2M.get('multiple').value"
mat-icon-button (click)="addInstances($event, objectLwM2M)"
matTooltip="{{'device-profile.lwm2m.add-new-instances' | translate}}"
matTooltipPosition="above">
<mat-icon class="material-icons">note_add</mat-icon>
</button>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-profile-lwm2m-observe-attr-telemetry-instances
formControlName="instances">
</tb-profile-lwm2m-observe-attr-telemetry-instances>
</ng-template>
</mat-expansion-panel>
@for (objectLwM2M of modelsFormArray.controls; track trackByParams($index, objectLwM2M.value)) {
<mat-expansion-panel [formGroupName]="$index">
<mat-expansion-panel-header>
<mat-panel-title class="flex items-center justify-start">
<span class="tb-panel-title">{{ objectLwM2M.get('name').value }}</span>&nbsp;#{{ objectLwM2M.get('keyId').value }}
<span class="flex-1"></span>
<tb-profile-lwm2m-attributes
formControlName="attributes"
[modelName]="getNameObject(objectLwM2M.value)">
</tb-profile-lwm2m-attributes>
@if (!disabled && objectLwM2M.get('multiple').value) {
<button type="button"
mat-icon-button
(click)="addInstances($event, objectLwM2M)"
matTooltip="{{ 'device-profile.lwm2m.add-new-instances' | translate }}"
matTooltipPosition="above">
<mat-icon class="material-icons">note_add</mat-icon>
</button>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-profile-lwm2m-observe-attr-telemetry-instances
formControlName="instances">
</tb-profile-lwm2m-observe-attr-telemetry-instances>
</ng-template>
</mat-expansion-panel>
}
</mat-accordion>
</section>

288
ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html

@ -19,156 +19,150 @@
<mat-checkbox formControlName="sparkplug">
{{ 'device-profile.mqtt-device-topic-filters-spark-plug' | translate }}
</mat-checkbox>
<div *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value"
class="tb-hint" innerHTML="{{ 'device-profile.mqtt-device-topic-filters-spark-plug-hint' | translate }}"></div>
<tb-string-items-list *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value"
editable
label="{{ 'device-profile.mqtt-device-topic-filters-spark-plug-attribute-metric-names' | translate }}"
placeholder="{{'device-profile.mqtt-device-topic-filters-spark-plug-attribute-metric-names' | translate}}"
hint="{{ 'device-profile.mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint' | translate }}"
floatLabel="always"
subscriptSizing="dynamic"
formControlName="sparkplugAttributesMetricNames">
</tb-string-items-list>
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value) {
<div class="tb-hint" [innerHTML]="'device-profile.mqtt-device-topic-filters-spark-plug-hint' | translate"></div>
<tb-string-items-list
editable
label="{{ 'device-profile.mqtt-device-topic-filters-spark-plug-attribute-metric-names' | translate }}"
placeholder="{{ 'device-profile.mqtt-device-topic-filters-spark-plug-attribute-metric-names' | translate }}"
hint="{{ 'device-profile.mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint' | translate }}"
floatLabel="always"
subscriptSizing="dynamic"
appearance="outline"
formControlName="sparkplugAttributesMetricNames">
</tb-string-items-list>
}
</form>
<form [formGroup]="mqttDeviceProfileTransportConfigurationFormGroup" style="padding-bottom: 16px;" *ngIf="!mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value">
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.mqtt-device-topic-filters</legend>
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 xs:flex-col">
<mat-form-field class="flex-1">
<mat-label translate>device-profile.telemetry-topic-filter</mat-label>
<input matInput required
formControlName="deviceTelemetryTopic"
type="text">
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('required')">
{{ 'device-profile.telemetry-topic-filter-required' | translate}}
</mat-error>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('invalidSingleTopicCharacter')">
{{ 'device-profile.not-valid-single-character' | translate}}
</mat-error>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('invalidMultiTopicCharacter')">
{{ 'device-profile.not-valid-multi-character' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="flex-1">
<mat-label translate>device-profile.attributes-topic-filter</mat-label>
<input matInput required
formControlName="deviceAttributesTopic"
type="text">
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('required')">
{{ 'device-profile.attributes-topic-filter-required' | translate}}
</mat-error>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('invalidSingleTopicCharacter')">
{{ 'device-profile.not-valid-single-character' | translate}}
</mat-error>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('invalidMultiTopicCharacter')">
{{ 'device-profile.not-valid-multi-character' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="flex-1">
<mat-label translate>device-profile.attributes-subscribe-topic-filter</mat-label>
<input matInput required
formControlName="deviceAttributesSubscribeTopic"
type="text">
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('required')">
{{ 'device-profile.attributes-subscribe-topic-filter-required' | translate}}
</mat-error>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('invalidSingleTopicCharacter')">
{{ 'device-profile.not-valid-single-character' | translate}}
</mat-error>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('invalidMultiTopicCharacter')">
{{ 'device-profile.not-valid-multi-character' | translate}}
</mat-error>
</mat-form-field>
</div>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.hasError('unique')">
{{ 'device-profile.mqtt-device-topic-filters-unique' | translate }}
</mat-error>
<div class="tb-hint" innerHTML="{{ 'device-profile.support-level-wildcards' | translate }}"></div>
<div class="tb-hint" innerHTML="{{ 'device-profile.single-level-wildcards-hint' | translate }}"></div>
<div class="tb-hint" innerHTML="{{ 'device-profile.multi-level-wildcards-hint' | translate }}"></div>
</div>
</fieldset>
<section formGroupName="transportPayloadTypeConfiguration">
@if (!mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value) {
<form [formGroup]="mqttDeviceProfileTransportConfigurationFormGroup" style="padding-bottom: 16px;">
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.mqtt-device-payload-type</legend>
<legend class="group-title" translate>device-profile.mqtt-device-topic-filters</legend>
<div class="flex flex-col gap-2">
<mat-form-field class="mat-block">
<mat-select formControlName="transportPayloadType" required>
<mat-option *ngFor="let type of transportPayloadTypes" [value]="type">
{{transportPayloadTypeTranslations.get(type) | translate}}
</mat-option>
</mat-select>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.transportPayloadType').hasError('required')">
{{ 'device-profile.mqtt-payload-type-required' | translate }}
</mat-error>
</mat-form-field>
<div *ngIf="protoPayloadType" style="padding-bottom: 20px">
<mat-checkbox formControlName="enableCompatibilityWithJsonPayloadFormat">
{{ 'device-profile.mqtt-enable-compatibility-with-json-payload-format' | translate }}
</mat-checkbox>
<div class="tb-hint" innerHTML="{{ 'device-profile.mqtt-enable-compatibility-with-json-payload-format-hint' | translate }}"></div>
<div *ngIf="compatibilityWithJsonPayloadFormatEnabled">
<mat-checkbox formControlName="useJsonPayloadFormatForDefaultDownlinkTopics">
{{ 'device-profile.mqtt-use-json-format-for-default-downlink-topics' | translate }}
</mat-checkbox>
<div class="tb-hint" innerHTML="{{ 'device-profile.mqtt-use-json-format-for-default-downlink-topics-hint' | translate }}"></div>
</div>
</div>
<div *ngIf="protoPayloadType" class="flex flex-col">
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')">
{{ 'device-profile.telemetry-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')">
{{ 'device-profile.attributes-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')">
{{ 'device-profile.rpc-request-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<ng-container>
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')">
{{ 'device-profile.rpc-response-proto-schema-required' | translate}}
</mat-error>
</ng-container>
<div class="flex flex-row gap-2 xs:flex-col">
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>device-profile.telemetry-topic-filter</mat-label>
<input matInput required formControlName="deviceTelemetryTopic" type="text">
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('required')) {
<mat-error>{{ 'device-profile.telemetry-topic-filter-required' | translate }}</mat-error>
}
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('invalidSingleTopicCharacter')) {
<mat-error>{{ 'device-profile.not-valid-single-character' | translate }}</mat-error>
}
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('invalidMultiTopicCharacter')) {
<mat-error>{{ 'device-profile.not-valid-multi-character' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>device-profile.attributes-topic-filter</mat-label>
<input matInput required formControlName="deviceAttributesTopic" type="text">
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('required')) {
<mat-error>{{ 'device-profile.attributes-topic-filter-required' | translate }}</mat-error>
}
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('invalidSingleTopicCharacter')) {
<mat-error>{{ 'device-profile.not-valid-single-character' | translate }}</mat-error>
}
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('invalidMultiTopicCharacter')) {
<mat-error>{{ 'device-profile.not-valid-multi-character' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>device-profile.attributes-subscribe-topic-filter</mat-label>
<input matInput required formControlName="deviceAttributesSubscribeTopic" type="text">
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('required')) {
<mat-error>{{ 'device-profile.attributes-subscribe-topic-filter-required' | translate }}</mat-error>
}
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('invalidSingleTopicCharacter')) {
<mat-error>{{ 'device-profile.not-valid-single-character' | translate }}</mat-error>
}
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('invalidMultiTopicCharacter')) {
<mat-error>{{ 'device-profile.not-valid-multi-character' | translate }}</mat-error>
}
</mat-form-field>
</div>
@if (mqttDeviceProfileTransportConfigurationFormGroup.hasError('unique')) {
<mat-error>{{ 'device-profile.mqtt-device-topic-filters-unique' | translate }}</mat-error>
}
<div class="tb-hint" [innerHTML]="'device-profile.support-level-wildcards' | translate"></div>
<div class="tb-hint" [innerHTML]="'device-profile.single-level-wildcards-hint' | translate"></div>
<div class="tb-hint" [innerHTML]="'device-profile.multi-level-wildcards-hint' | translate"></div>
</div>
</fieldset>
</section>
<div style="padding-bottom: 20px">
<mat-checkbox formControlName="sendAckOnValidationException">
{{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }}
</mat-checkbox>
<div class="tb-hint" style="max-width: 800px" innerHTML="{{ 'device-profile.mqtt-send-ack-on-validation-exception-hint' | translate }}"></div>
</div>
</form>
<section formGroupName="transportPayloadTypeConfiguration">
<fieldset class="fields-group">
<legend class="group-title" translate>device-profile.mqtt-device-payload-type</legend>
<div class="flex flex-col gap-2">
<mat-form-field class="mat-block" appearance="outline">
<mat-select formControlName="transportPayloadType" required>
@for (type of transportPayloadTypes; track type) {
<mat-option [value]="type">
{{ getTransportPayloadType(type) | translate }}
</mat-option>
}
</mat-select>
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.transportPayloadType').hasError('required')) {
<mat-error>{{ 'device-profile.mqtt-payload-type-required' | translate }}</mat-error>
}
</mat-form-field>
@if (protoPayloadType) {
<div style="padding-bottom: 20px">
<mat-checkbox formControlName="enableCompatibilityWithJsonPayloadFormat">
{{ 'device-profile.mqtt-enable-compatibility-with-json-payload-format' | translate }}
</mat-checkbox>
<div class="tb-hint" [innerHTML]="'device-profile.mqtt-enable-compatibility-with-json-payload-format-hint' | translate"></div>
@if (compatibilityWithJsonPayloadFormatEnabled) {
<mat-checkbox formControlName="useJsonPayloadFormatForDefaultDownlinkTopics">
{{ 'device-profile.mqtt-use-json-format-for-default-downlink-topics' | translate }}
</mat-checkbox>
<div class="tb-hint" [innerHTML]="'device-profile.mqtt-use-json-format-for-default-downlink-topics-hint' | translate"></div>
}
</div>
<div class="flex flex-col">
<tb-protobuf-content
class="flex-1"
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.telemetry-proto-schema-required' | translate }}</mat-error>
}
<tb-protobuf-content
class="flex-1"
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.attributes-proto-schema-required' | translate }}</mat-error>
}
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.rpc-request-proto-schema-required' | translate }}</mat-error>
}
<tb-protobuf-content
class="flex-1"
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
@if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')) {
<mat-error>{{ 'device-profile.rpc-response-proto-schema-required' | translate }}</mat-error>
}
</div>
}
</div>
</fieldset>
</section>
<div style="padding-bottom: 20px">
<mat-checkbox formControlName="sendAckOnValidationException">
{{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }}
</mat-checkbox>
<div class="tb-hint" style="max-width: 800px" [innerHTML]="'device-profile.mqtt-send-ack-on-validation-exception-hint' | translate"></div>
</div>
</form>
}

4
ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts

@ -252,4 +252,8 @@ export class MqttDeviceProfileTransportConfigurationComponent implements Control
}
return null;
}
getTransportPayloadType(type: string): string {
return this.transportPayloadTypeTranslations.get(type as TransportPayloadType);
}
}

109
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html

@ -16,57 +16,66 @@
-->
<div class="flex flex-col">
<div *ngFor="let deviceProfileCommunication of communicationConfigFormArray.controls; let $index = index;
last as isLast;"
class="scope-row flex flex-row items-center justify-start gap-2" [formGroup]="deviceProfileCommunication">
<div class="communication-config">
<mat-form-field class="spec mat-block" floatLabel="always" hideRequiredMarker>
<mat-label translate>device-profile.snmp.scope</mat-label>
<mat-select formControlName="spec" required>
<mat-option *ngFor="let snmpSpecType of snmpSpecTypes" [value]="snmpSpecType"
[disabled]="isDisabledSeverity(snmpSpecType, $index)">
{{ snmpSpecTypeTranslationMap.get(snmpSpecType) }}
</mat-option>
</mat-select>
<mat-error *ngIf="deviceProfileCommunication.get('spec').hasError('required')">
{{ 'device-profile.snmp.scope-required' | translate }}
</mat-error>
</mat-form-field>
<mat-divider vertical></mat-divider>
<section class="flex flex-1 flex-col">
<mat-form-field *ngIf="isShowFrequency(deviceProfileCommunication.get('spec').value)">
<mat-label translate>device-profile.snmp.querying-frequency</mat-label>
<input matInput formControlName="queryingFrequencyMs" type="number" min="0" required/>
<mat-error *ngIf="deviceProfileCommunication.get('queryingFrequencyMs').hasError('required')">
{{ 'device-profile.snmp.querying-frequency-required' | translate }}
</mat-error>
<mat-error *ngIf="deviceProfileCommunication.get('queryingFrequencyMs').hasError('pattern') ||
deviceProfileCommunication.get('queryingFrequencyMs').hasError('min')">
{{ 'device-profile.snmp.querying-frequency-invalid-format' | translate }}
</mat-error>
@for (deviceProfileCommunication of communicationConfigFormArray.controls; track $index) {
<div class="scope-row flex flex-row items-center justify-start gap-2" [formGroup]="deviceProfileCommunication">
<div class="communication-config">
<mat-form-field class="spec mat-block" appearance="outline" floatLabel="always" hideRequiredMarker>
<mat-label translate>device-profile.snmp.scope</mat-label>
<mat-select formControlName="spec" required>
@for (snmpSpecType of snmpSpecTypes; track snmpSpecType) {
<mat-option [value]="snmpSpecType"
[disabled]="isDisabledSeverity(snmpSpecType, $index)">
{{ snmpSpecTypeTranslationMap.get(snmpSpecType) }}
</mat-option>
}
</mat-select>
@if (deviceProfileCommunication.get('spec').hasError('required')) {
<mat-error>{{ 'device-profile.snmp.scope-required' | translate }}</mat-error>
}
</mat-form-field>
<tb-snmp-device-profile-mapping formControlName="mappings">
</tb-snmp-device-profile-mapping>
</section>
<mat-divider vertical></mat-divider>
<section class="flex flex-1 flex-col">
@if (isShowFrequency(deviceProfileCommunication.get('spec').value)) {
<mat-form-field appearance="outline">
<mat-label translate>device-profile.snmp.querying-frequency</mat-label>
<input matInput formControlName="queryingFrequencyMs" type="number" min="0" required/>
@if (deviceProfileCommunication.get('queryingFrequencyMs').hasError('required')) {
<mat-error>{{ 'device-profile.snmp.querying-frequency-required' | translate }}</mat-error>
}
@if (deviceProfileCommunication.get('queryingFrequencyMs').hasError('pattern') ||
deviceProfileCommunication.get('queryingFrequencyMs').hasError('min')) {
<mat-error>{{ 'device-profile.snmp.querying-frequency-invalid-format' | translate }}</mat-error>
}
</mat-form-field>
}
<tb-snmp-device-profile-mapping formControlName="mappings">
</tb-snmp-device-profile-mapping>
</section>
</div>
@if (!disabled) {
<button mat-icon-button color="primary" style="min-width: 40px;"
type="button"
(click)="removeCommunicationConfig($index)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>remove_circle_outline</mat-icon>
</button>
}
</div>
<button *ngIf="!disabled"
mat-icon-button color="primary" style="min-width: 40px;"
type="button"
(click)="removeCommunicationConfig($index)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>remove_circle_outline</mat-icon>
</button>
</div>
<div *ngIf="!communicationConfigFormArray.controls.length && !disabled">
}
@if (!communicationConfigFormArray.controls.length && !disabled) {
<div>
<span class="tb-prompt required required-text flex items-center justify-center" translate>device-profile.snmp.please-add-communication-config</span>
</div>
<div *ngIf="!disabled && isAddEnabled">
<button mat-stroked-button color="primary"
type="button"
(click)="addCommunicationConfig()">
<mat-icon class="button-icon">add_circle_outline</mat-icon>
{{ 'device-profile.snmp.add-communication-config' | translate }}
</button>
</div>
</div>
}
@if (!disabled && isAddEnabled) {
<div>
<button mat-stroked-button color="primary"
type="button"
(click)="addCommunicationConfig()">
<mat-icon class="button-icon">add_circle_outline</mat-icon>
{{ 'device-profile.snmp.add-communication-config' | translate }}
</button>
</div>
}
</div>

6
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts

@ -24,7 +24,7 @@ import {
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator,
Validators
Validators, FormArray, FormGroup
} from '@angular/forms';
import { SnmpCommunicationConfig, SnmpSpecType, SnmpSpecTypeTranslationMap } from '@shared/models/device.models';
import { Subject } from 'rxjs';
@ -79,8 +79,8 @@ export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, On
this.destroy$.complete();
}
get communicationConfigFormArray(): UntypedFormArray {
return this.deviceProfileCommunicationConfig.get('communicationConfig') as UntypedFormArray;
get communicationConfigFormArray(): FormArray<FormGroup> {
return this.deviceProfileCommunicationConfig.get('communicationConfig') as FormArray<FormGroup>;
}
registerOnChange(fn: any): void {

32
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html

@ -17,27 +17,27 @@
-->
<form [formGroup]="snmpDeviceProfileTransportConfigurationFormGroup" style="padding: 8px 0 16px;">
<section class="flex flex-row gap-2 xs:flex-col">
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>device-profile.snmp.timeout-ms</mat-label>
<input matInput formControlName="timeoutMs" type="number" min="0" required/>
<mat-error *ngIf="snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('required')">
{{ 'device-profile.snmp.timeout-ms-required' | translate }}
</mat-error>
<mat-error *ngIf="snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('pattern') ||
snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('min')">
{{ 'device-profile.snmp.timeout-ms-invalid-format' | translate }}
</mat-error>
@if (snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('required')) {
<mat-error>{{ 'device-profile.snmp.timeout-ms-required' | translate }}</mat-error>
}
@if (snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('pattern') ||
snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('min')) {
<mat-error>{{ 'device-profile.snmp.timeout-ms-invalid-format' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>device-profile.snmp.retries</mat-label>
<input matInput formControlName="retries" type="number" min="0" required/>
<mat-error *ngIf="snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('required')">
{{ 'device-profile.snmp.retries-required' | translate }}
</mat-error>
<mat-error *ngIf="snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('pattern') ||
snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('min')">
{{ 'device-profile.snmp.retries-invalid-format' | translate }}
</mat-error>
@if (snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('required')) {
<mat-error>{{ 'device-profile.snmp.retries-required' | translate }}</mat-error>
}
@if (snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('pattern') ||
snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('min')) {
<mat-error>{{ 'device-profile.snmp.retries-invalid-format' | translate }}</mat-error>
}
</mat-form-field>
</section>
<div class="tb-small" style="padding-bottom: 8px" translate>device-profile.snmp.communication-configs</div>

94
ui-ngx/src/app/modules/home/components/resources/resources-library.component.html

@ -15,57 +15,67 @@
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'downloadResource')"
[class.!hidden]="isEdit">
{{ 'resource.download' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'deleteResource')"
[class.!hidden]="hideDelete() || isEdit">
{{ 'resource.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onResourceIdCopied()"
[cbContent]="entity?.id?.id"
@if (!standalone) {
<div class="tb-details-buttons xs:flex xs:flex-col">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'downloadResource')"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>resource.copyId</span>
{{ 'resource.download' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'deleteResource')"
[class.!hidden]="hideDelete() || isEdit">
{{ 'resource.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onResourceIdCopied()"
[cbContent]="entity?.id?.id"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>resource.copyId</span>
</button>
</div>
</div>
</div>
}
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>resource.resource-type</mat-label>
<mat-select formControlName="resourceType" required>
<mat-option *ngFor="let resourceType of resourceTypes" [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
@for (resourceType of resourceTypes ; track resourceType) {
<mat-option [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block" *ngIf="entityForm.get('resourceType').value !== resourceType.LWM2M_MODEL || !isAdd">
<mat-label translate>resource.title</mat-label>
<input matInput formControlName="title" required>
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'resource.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')">
{{ 'resource.title-max-length' | translate }}
</mat-error>
</mat-form-field>
@if (entityForm.get('resourceType').value !== resourceType.LWM2M_MODEL || !isAdd) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>resource.title</mat-label>
<input matInput formControlName="title" required>
@if (entityForm.get('title').hasError('required')) {
<mat-error>
{{ 'resource.title-required' | translate }}
</mat-error>
}
@if (entityForm.get('title').hasError('maxlength')) {
<mat-error>
{{ 'resource.title-max-length' | translate }}
</mat-error>
}
</mat-form-field>
}
@if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) {
<tb-file-input formControlName="data"
required
@ -83,7 +93,7 @@
</tb-file-input>
} @else {
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1">
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>

2
ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field [formGroup]="selectRuleChainFormGroup" class="mat-block">
<mat-form-field [formGroup]="selectRuleChainFormGroup" class="mat-block" [appearance]="appearance">
<mat-label>{{ labelText | translate }}</mat-label>
<input matInput type="text"
#ruleChainInput

4
ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts

@ -31,6 +31,7 @@ import { RuleChainService } from '@core/http/rule-chain.service';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { RuleChainType } from '@app/shared/models/rule-chain.models';
import { getEntityDetailsPageURL } from '@core/utils';
import { MatFormFieldAppearance } from '@angular/material/form-field';
@Component({
selector: 'tb-rule-chain-autocomplete',
@ -58,6 +59,9 @@ export class RuleChainAutocompleteComponent implements ControlValueAccessor, OnI
@Input()
ruleChainType: RuleChainType = RuleChainType.CORE;
@Input()
appearance: MatFormFieldAppearance = 'fill';
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;

84
ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html

@ -26,20 +26,26 @@
<span class="flex-1"></span>
<div tb-help="repositorySettings"></div>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="!hideLoadingBar && isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="hideLoadingBar || (isLoading$ | async) === false"></div>
@if (!hideLoadingBar && isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate">
</mat-progress-bar>
}
@if (hideLoadingBar || (isLoading$ | async) === false) {
<div style="height: 4px;"></div>
}
<mat-card-content style="padding-top: 16px;">
<form [formGroup]="repositorySettingsForm" #formDirective="ngForm" (ngSubmit)="save()">
<fieldset [disabled]="isLoading$ | async">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>admin.repository-url</mat-label>
<input matInput required formControlName="repositoryUri">
<mat-error translate *ngIf="repositorySettingsForm.get('repositoryUri').hasError('required')">
admin.repository-url-required
</mat-error>
@if (repositorySettingsForm.get('repositoryUri').hasError('required')) {
<mat-error translate>
admin.repository-url-required
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>admin.default-branch</mat-label>
<input matInput formControlName="defaultBranch">
</mat-form-field>
@ -53,31 +59,37 @@
</div>
<fieldset [disabled]="isLoading$ | async" class="fields-group">
<legend class="group-title" translate>admin.authentication-settings</legend>
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>admin.auth-method</mat-label>
<mat-select required formControlName="authMethod">
<mat-option *ngFor="let method of repositoryAuthMethods" [value]="method">
{{repositoryAuthMethodTranslations.get(method) | translate}}
</mat-option>
@for ( method of repositoryAuthMethods; track method) {
<mat-option [value]="method">
{{repositoryAuthMethodTranslations.get(method) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<section [class.!hidden]="repositorySettingsForm.get('authMethod').value !== repositoryAuthMethod.USERNAME_PASSWORD" class="flex flex-col">
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>common.username</mat-label>
<input matInput formControlName="username" placeholder="{{ 'common.enter-username' | translate }}"
autocomplete="new-username"/>
</mat-form-field>
<mat-checkbox *ngIf="showChangePassword" (change)="changePasswordChanged()"
[(ngModel)]="changePassword" [ngModelOptions]="{ standalone: true }">
{{ 'admin.change-password-access-token' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block" *ngIf="changePassword || !showChangePassword">
<mat-label translate>admin.password-access-token</mat-label>
<input matInput formControlName="password" type="password"
placeholder="{{ 'common.enter-password' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-hint [innerHTML] = "'admin.auth-method-username-password-hint' | translate"></mat-hint>
</mat-form-field>
@if (showChangePassword) {
<mat-checkbox (change)="changePasswordChanged()"
[(ngModel)]="changePassword" [ngModelOptions]="{ standalone: true }">
{{ 'admin.change-password-access-token' | translate }}
</mat-checkbox>
}
@if (changePassword || !showChangePassword) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>admin.password-access-token</mat-label>
<input matInput formControlName="password" type="password"
placeholder="{{ 'common.enter-password' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-hint [innerHTML] = "'admin.auth-method-username-password-hint' | translate"></mat-hint>
</mat-form-field>
}
</section>
<section [class.!hidden]="repositorySettingsForm.get('authMethod').value !== repositoryAuthMethod.PRIVATE_KEY" class="flex flex-col">
<tb-file-input [existingFileName]="repositorySettingsForm.get('privateKeyFileName').value"
@ -87,16 +99,20 @@
[label]="'admin.private-key' | translate"
(fileNameChanged)="repositorySettingsForm.get('privateKeyFileName').patchValue($event)">
</tb-file-input>
<mat-checkbox *ngIf="showChangePrivateKeyPassword" (change)="changePrivateKeyPasswordChanged()"
[(ngModel)]="changePrivateKeyPassword" [ngModelOptions]="{ standalone: true }">
{{ 'admin.change-passphrase' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block" *ngIf="changePrivateKeyPassword || !showChangePrivateKeyPassword">
<mat-label translate>admin.passphrase</mat-label>
<input matInput formControlName="privateKeyPassword" type="password"
placeholder="{{ 'admin.enter-passphrase' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
@if (showChangePrivateKeyPassword) {
<mat-checkbox (change)="changePrivateKeyPasswordChanged()"
[(ngModel)]="changePrivateKeyPassword" [ngModelOptions]="{ standalone: true }">
{{ 'admin.change-passphrase' | translate }}
</mat-checkbox>
}
@if (changePrivateKeyPassword || !showChangePrivateKeyPassword) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>admin.passphrase</mat-label>
<input matInput formControlName="privateKeyPassword" type="password"
placeholder="{{ 'admin.enter-passphrase' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
}
</section>
</fieldset>
<div class="flex flex-row items-center justify-end gap-4 xs:flex-col xs:items-stretch xs:justify-end">

65
ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html

@ -25,8 +25,11 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate"></mat-progress-bar>
} @else {
<div style="height: 4px;"></div>
}
<div mat-dialog-content>
<mat-horizontal-stepper #addDeviceWizardStepper
[linear]="true"
@ -38,29 +41,30 @@
</ng-template>
<mat-step [stepControl]="deviceWizardFormGroup">
<form [formGroup]="deviceWizardFormGroup" style="padding-bottom: 16px;">
<ng-template matStepLabel>{{ 'device.wizard.device-details' | translate}}</ng-template>
<fieldset [disabled]="isLoading$ | async">
<mat-form-field class="mat-block">
<ng-template matStepLabel>{{ 'device.wizard.device-details' | translate }}</ng-template>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col gap-1">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="deviceWizardFormGroup.get('name').hasError('required')">
{{ 'device.name-required' | translate }}
</mat-error>
<mat-error *ngIf="deviceWizardFormGroup.get('name').hasError('maxlength')">
{{ 'device.name-max-length' | translate }}
</mat-error>
@if (deviceWizardFormGroup.get('name').hasError('required')) {
<mat-error>{{ 'device.name-required' | translate }}</mat-error>
}
@if (deviceWizardFormGroup.get('name').hasError('maxlength')) {
<mat-error>{{ 'device.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.label</mat-label>
<input matInput formControlName="label">
<mat-error *ngIf="deviceWizardFormGroup.get('label').hasError('maxlength')">
{{ 'device.label-max-length' | translate }}
</mat-error>
@if (deviceWizardFormGroup.get('label').hasError('maxlength')) {
<mat-error>{{ 'device.label-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-device-profile-autocomplete
required
addNewProfile
selectDefaultProfile
appearance="outline"
formControlName="deviceProfileId"
(deviceProfileChanged)="deviceProfileChanged($event)">
</tb-device-profile-autocomplete>
@ -68,18 +72,20 @@
<mat-slide-toggle formControlName="gateway" style="min-width: 165px">
{{ 'device.is-gateway' | translate }}
</mat-slide-toggle>
<mat-slide-toggle *ngIf="deviceWizardFormGroup.get('gateway').value"
formControlName="overwriteActivityTime">
{{ 'device.overwrite-activity-time' | translate }}
</mat-slide-toggle>
@if (deviceWizardFormGroup.get('gateway').value) {
<mat-slide-toggle formControlName="overwriteActivityTime">
{{ 'device.overwrite-activity-time' | translate }}
</mat-slide-toggle>
}
</div>
<tb-entity-autocomplete
useFullEntityId
formControlName="customerId"
labelText="device.assign-to-customer"
appearance="outline"
[entityType]="entityType.CUSTOMER">
</tb-entity-autocomplete>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-form-field class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>device.description</mat-label>
<textarea matInput formControlName="description" rows="1"></textarea>
</mat-form-field>
@ -100,15 +106,18 @@
</div>
<div mat-dialog-actions class="tb-dialog-actions">
<div class="dialog-actions-row">
<button mat-stroked-button *ngIf="selectedIndex > 0"
[disabled]="(isLoading$ | async)"
(click)="previousStep()">{{ 'action.back' | translate }}</button>
@if (selectedIndex > 0) {
<button mat-stroked-button
[disabled]="(isLoading$ | async)"
(click)="previousStep()">{{ 'action.back' | translate }}</button>
}
<span class="flex-1"></span>
<button mat-stroked-button
color="primary"
*ngIf="showNext"
[disabled]="(isLoading$ | async)"
(click)="nextStep()">{{ 'action.next-with-label' | translate:{label: (getFormLabel(selectedIndex+1) | translate)} }}</button>
@if (showNext) {
<button mat-stroked-button
color="primary"
[disabled]="(isLoading$ | async)"
(click)="nextStep()">{{ 'action.next-with-label' | translate:{label: (getFormLabel(selectedIndex+1) | translate)} }}</button>
}
</div>
<mat-divider style="width: 100%"></mat-divider>
<div class="dialog-actions-row">

1
ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss

@ -74,6 +74,7 @@
max-height: 100%;
overflow-y: auto;
scrollbar-gutter: stable;
padding-top: 4px;
}
}
}

125
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html

@ -48,71 +48,82 @@
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>javascript.javascript-type</mat-label>
<mat-select formControlName="resourceSubType" required>
<mat-option *ngFor="let resourceSubType of jsResourceSubTypes" [value]="resourceSubType">
{{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }}
</mat-option>
@for (resourceSubType of jsResourceSubTypes; track resourceSubType) {
<mat-option [value]="resourceSubType">
{{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>resource.title</mat-label>
<input matInput formControlName="title" required>
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'resource.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')">
{{ 'resource.title-max-length' | translate }}
</mat-error>
@if (entityForm.get('title').hasError('required')) {
<mat-error>
{{ 'resource.title-required' | translate }}
</mat-error>
}
@if (entityForm.get('title').hasError('maxlength')) {
<mat-error>
{{ 'resource.title-max-length' | translate }}
</mat-error>
}
</mat-form-field>
<tb-file-input *ngIf="(isAdd || isEdit) && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION"
formControlName="data"
[required]="isAdd"
label="{{ 'javascript.resource-file' | translate }}"
[workFromFileObj]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
dropLabel="{{'javascript.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
<tb-js-func *ngIf="entityForm.get('resourceSubType').value === ResourceSubType.MODULE"
[helpPopupStyle]="{ width: '1000px' }"
helpId="resource/js-resource-module_fn"
formControlName="content"
required
hideBrackets
hideLabel
minHeight="300px">
<div toolbarStartButton
class="flex flex-row gap-4">
<label class="tb-title no-padding tb-required"
[class.tb-error]="entityForm.get('content').invalid && entityForm.get('content').touched"
style="font-size: 16px;">
{{ 'javascript.module-script' | translate }}</label>
<tb-file-input *ngIf="(isAdd || isEdit)"
asButton
uploadButtonText="{{ 'javascript.upload-from-file' | translate }}"
uploadButtonClass="tb-ignore-browse-file-button-style"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[accept]="getAcceptType()"
[ngModel]=""
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="uploadContentFromFile($event)"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
@if ((isAdd || isEdit) && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION) {
<tb-file-input formControlName="data"
[required]="isAdd"
label="{{ 'javascript.resource-file' | translate }}"
[workFromFileObj]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
dropLabel="{{'javascript.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
}
@if (entityForm.get('resourceSubType').value === ResourceSubType.MODULE) {
<tb-js-func [helpPopupStyle]="{ width: '1000px' }"
helpId="resource/js-resource-module_fn"
formControlName="content"
required
hideBrackets
hideLabel
minHeight="300px">
<div toolbarStartButton
class="flex flex-row gap-4">
<label class="tb-title no-padding tb-required"
[class.tb-error]="entityForm.get('content').invalid && entityForm.get('content').touched"
style="font-size: 16px;">
{{ 'javascript.module-script' | translate }}</label>
@if ((isAdd || isEdit)) {
<tb-file-input asButton
uploadButtonText="{{ 'javascript.upload-from-file' | translate }}"
uploadButtonClass="tb-ignore-browse-file-button-style"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[accept]="getAcceptType()"
[ngModel]=""
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="uploadContentFromFile($event)"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
}
</div>
</tb-js-func>
}
@if (!isAdd && !isEdit && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION) {
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1" appearance="outline">
<mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
</div>
</tb-js-func>
<div *ngIf="!isAdd && !isEdit && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1">
<mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
</div>
}
</fieldset>
</form>
</div>

2
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts

@ -173,4 +173,6 @@ export class JsResourceComponent extends EntityComponent<Resource> implements On
this.entityForm.get('content').enable({ emitEvent: false });
}
}
protected readonly resourceType = ResourceType;
}

45
ui-ngx/src/app/modules/home/pages/asset/asset.component.html

@ -64,7 +64,7 @@
</div>
</div>
<div class="mat-padding flex flex-col">
<mat-form-field class="mat-block"
<mat-form-field class="mat-block" appearance="outline"
[class.!hidden]="isEdit || !isAssignedToCustomer(entity)
|| entity?.customerIsPublic || assetScope !== 'tenant'">
<mat-label translate>asset.assignedToCustomer</mat-label>
@ -75,40 +75,43 @@
{{ 'asset.asset-public' | translate }}
</div>
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>asset.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'asset.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'asset.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'asset.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'asset.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>asset.label</mat-label>
<input matInput formControlName="label">
<mat-error *ngIf="entityForm.get('label').hasError('maxlength')">
{{ 'asset.label-max-length' | translate }}
</mat-error>
@if (entityForm.get('label').hasError('maxlength')) {
<mat-error>{{ 'asset.label-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-asset-profile-autocomplete
[selectDefaultProfile]="isAdd"
required
formControlName="assetProfileId"
appearance="outline"
[showDetailsPageLink]="true"
(assetProfileUpdated)="onAssetProfileUpdated()">
</tb-asset-profile-autocomplete>
<tb-entity-autocomplete
*ngIf="isAdd"
useFullEntityId
formControlName="customerId"
labelText="asset.assign-to-customer"
[entityType]="entityType.CUSTOMER">
</tb-entity-autocomplete>
@if (isAdd) {
<tb-entity-autocomplete
useFullEntityId
formControlName="customerId"
labelText="asset.assign-to-customer"
appearance="outline"
[entityType]="entityType.CUSTOMER">
</tb-entity-autocomplete>
}
<div formGroupName="additionalInfo">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>asset.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

64
ui-ngx/src/app/modules/home/pages/customer/customer.component.html

@ -46,13 +46,14 @@
[class.!hidden]="isEdit">
{{'customer.manage-dashboards' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'manageEdges')"
[class.!hidden]="isEdit"
*ngIf="edgesSupportEnabled()">
{{'customer.manage-edges' | translate }}
</button>
@if (edgesSupportEnabled()) {
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'manageEdges')"
[class.!hidden]="isEdit">
{{'customer.manage-edges' | translate }}
</button>
}
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
@ -72,35 +73,38 @@
</div>
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [class.!hidden]="isPublic" [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [class.!hidden]="isPublic" [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>customer.title</mat-label>
<input matInput formControlName="title" required/>
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'customer.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')">
{{ 'customer.title-max-length' | translate }}
</mat-error>
@if (entityForm.get('title').hasError('required')) {
<mat-error>{{ 'customer.title-required' | translate }}</mat-error>
}
@if (entityForm.get('title').hasError('maxlength')) {
<mat-error>{{ 'customer.title-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-contact [parentForm]="entityForm" [isEdit]="isEdit"></tb-contact>
<div formGroupName="additionalInfo" class="flex flex-col">
<section class="tb-default-dashboard flex flex-1 flex-col" *ngIf="entity?.id">
<section class="flex flex-1 flex-col gt-sm:flex-row">
<tb-dashboard-autocomplete
class="flex-1"
label="{{ 'dashboard.home-dashboard' | translate }}"
formControlName="homeDashboardId"
[dashboardsScope]="'customer'"
[customerId]="entity?.id.id"
[selectFirstDashboard]="false"
></tb-dashboard-autocomplete>
<mat-checkbox class="flex-1" formControlName="homeDashboardHideToolbar">
{{ 'dashboard.home-dashboard-hide-toolbar' | translate }}
</mat-checkbox>
@if (entity?.id) {
<section class="tb-default-dashboard flex flex-1 flex-col">
<section class="flex flex-1 flex-col gt-sm:flex-row">
<tb-dashboard-autocomplete
class="flex-1"
label="{{ 'dashboard.home-dashboard' | translate }}"
formControlName="homeDashboardId"
appearance="outline"
[dashboardsScope]="'customer'"
[customerId]="entity?.id.id"
[selectFirstDashboard]="false">
</tb-dashboard-autocomplete>
<mat-checkbox class="flex-1" formControlName="homeDashboardHideToolbar">
{{ 'dashboard.home-dashboard-hide-toolbar' | translate }}
</mat-checkbox>
</section>
</section>
</section>
<mat-form-field class="mat-block">
}
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>customer.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

38
ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html

@ -83,7 +83,7 @@
</div>
</div>
<div class="mat-padding flex flex-col">
<mat-form-field class="mat-block"
<mat-form-field class="mat-block" appearance="outline"
[class.!hidden]="isEdit || !assignedCustomersText?.length
|| dashboardScope !== 'tenant'">
<mat-label translate>dashboard.assignedToCustomers</mat-label>
@ -98,7 +98,7 @@
shareHashTags="thingsboard, iot">
</tb-social-share-panel>
<div class="flex flex-row">
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>dashboard.public-link</mat-label>
<input matInput disabled [ngModel]="publicLink">
</mat-form-field>
@ -113,29 +113,31 @@
</div>
</div>
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>dashboard.title</mat-label>
<input matInput formControlName="title" required>
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'dashboard.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')">
{{ 'dashboard.title-max-length' | translate }}
</mat-error>
@if (entityForm.get('title').hasError('required')) {
<mat-error>{{ 'dashboard.title-required' | translate }}</mat-error>
}
@if (entityForm.get('title').hasError('maxlength')) {
<mat-error>{{ 'dashboard.title-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-entity-list
*ngIf="isAdd"
formControlName="assignedCustomerIds"
labelText="{{ 'dashboard.assigned-customers' | translate }}"
[entityType]="entityType.CUSTOMER">
</tb-entity-list>
@if (isAdd) {
<tb-entity-list
formControlName="assignedCustomerIds"
labelText="{{ 'dashboard.assigned-customers' | translate }}"
appearance="outline"
[entityType]="entityType.CUSTOMER">
</tb-entity-list>
}
<div class="tb-form-panel stroked no-gap">
<div class="tb-form-panel-title" translate>dashboard.mobile-app-settings</div>
<mat-slide-toggle formControlName="mobileHide" class="mat-slide margin fixed-title-width">
{{ 'dashboard.mobile-hide' | translate }}
</mat-slide-toggle>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>dashboard.mobile-order</mat-label>
<input matInput formControlName="mobileOrder" type="number" step="1">
</mat-form-field>
@ -145,7 +147,7 @@
</tb-gallery-image-input>
</div>
<div formGroupName="configuration" class="flex flex-col mt-5">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>dashboard.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

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

@ -83,7 +83,7 @@
</div>
</div>
<div class="mat-padding flex flex-col">
<mat-form-field class="mat-block"
<mat-form-field class="mat-block" appearance="outline"
[class.!hidden]="isEdit || !isAssignedToCustomer(entity)
|| entity?.customerIsPublic || deviceScope !== 'tenant'">
<mat-label translate>device.assignedToCustomer</mat-label>
@ -94,37 +94,39 @@
{{ 'device.device-public' | translate }}
</div>
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'device.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'device.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'device.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'device.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-device-profile-autocomplete
[selectDefaultProfile]="isAdd"
required
formControlName="deviceProfileId"
appearance="outline"
[showDetailsPageLink]="true"
(deviceProfileUpdated)="onDeviceProfileUpdated()"
(deviceProfileChanged)="onDeviceProfileChanged($event)">
</tb-device-profile-autocomplete>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.label</mat-label>
<input matInput formControlName="label">
<mat-error *ngIf="entityForm.get('label').hasError('maxlength')">
{{ 'device.label-max-length' | translate }}
</mat-error>
@if (entityForm.get('label').hasError('maxlength')) {
<mat-error>{{ 'device.label-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-ota-package-autocomplete
[useFullEntityId]="true"
[showDetailsPageLink]="true"
[type]="otaUpdateType.FIRMWARE"
[deviceProfileId]="entityForm.get('deviceProfileId').value?.id"
appearance="outline"
formControlName="firmwareId">
</tb-ota-package-autocomplete>
<tb-ota-package-autocomplete
@ -132,6 +134,7 @@
[showDetailsPageLink]="true"
[type]="otaUpdateType.SOFTWARE"
[deviceProfileId]="entityForm.get('deviceProfileId').value?.id"
appearance="outline"
formControlName="softwareId">
</tb-ota-package-autocomplete>
<tb-device-data
@ -143,12 +146,13 @@
<mat-slide-toggle formControlName="gateway" style="min-width: 165px">
{{ 'device.is-gateway' | translate }}
</mat-slide-toggle>
<mat-slide-toggle *ngIf="entityForm.get('additionalInfo.gateway').value"
formControlName="overwriteActivityTime">
{{ 'device.overwrite-activity-time' | translate }}
</mat-slide-toggle>
@if (entityForm.get('additionalInfo.gateway').value) {
<mat-slide-toggle formControlName="overwriteActivityTime">
{{ 'device.overwrite-activity-time' | translate }}
</mat-slide-toggle>
}
</div>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>device.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

90
ui-ngx/src/app/modules/home/pages/edge/edge.component.html

@ -114,30 +114,27 @@
</button>
</div>
<div class="flex flex-row xs:flex-col">
<div [ngSwitch]="upgradeAvailable">
<ng-container *ngSwitchCase="false">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'openInstallInstructions')"
[class.!hidden]="isEdit || edgeScope === 'customer_user'">
<mat-icon>info_outline</mat-icon>
<span>{{ 'edge.install-connect-instructions' | translate }}</span>
</button>
</ng-container>
<ng-container *ngSwitchCase="true">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'openUpgradeInstructions')"
[class.!hidden]="isEdit || edgeScope === 'customer_user'">
<mat-icon>info_outline</mat-icon>
<span>{{ 'edge.upgrade-instructions' | translate }}</span>
</button>
</ng-container>
</div>
@if (!upgradeAvailable) {
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'openInstallInstructions')"
[class.!hidden]="isEdit || edgeScope === 'customer_user'">
<mat-icon>info_outline</mat-icon>
<span>{{ 'edge.install-connect-instructions' | translate }}</span>
</button>
} @else {
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'openUpgradeInstructions')"
[class.!hidden]="isEdit || edgeScope === 'customer_user'">
<mat-icon>info_outline</mat-icon>
<span>{{ 'edge.upgrade-instructions' | translate }}</span>
</button>
}
</div>
</div>
<div class="mat-padding flex flex-col">
<mat-form-field class="mat-block"
<mat-form-field class="mat-block" appearance="outline"
[class.!hidden]="isEdit || !isAssignedToCustomer(entity)
|| entity?.customerIsPublic || edgeScope !== 'tenant'">
<mat-label translate>edge.assignedToCustomer</mat-label>
@ -148,55 +145,58 @@
{{ 'edge.edge-public' | translate }}
</div>
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>edge.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'edge.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'edge.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'edge.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'edge.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-entity-subtype-autocomplete
formControlName="type"
appearance="outline"
[required]="true"
[entityType]="entityType.EDGE">
</tb-entity-subtype-autocomplete>
</fieldset>
<div class="flex flex-row" [class.!hidden]="edgeScope === 'customer_user'">
<mat-form-field class="mat-block flex-1">
<mat-label translate>edge.edge-key</mat-label>
<input matInput formControlName="routingKey">
</mat-form-field>
<button mat-icon-button type="button" ngxClipboard (cbOnSuccess)="onEdgeInfoCopied('key')"
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>edge.edge-key</mat-label>
<input matInput formControlName="routingKey">
</mat-form-field>
<button mat-icon-button type="button" ngxClipboard
(cbOnSuccess)="onEdgeInfoCopied('key')"
[cbContent]="entityForm?.get('routingKey').value"
matTooltip="{{'edge.copy-edge-key' | translate}}">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
</button>
</div>
<div class="flex flex-row" [class.!hidden]="edgeScope === 'customer_user'">
<mat-form-field class="mat-block flex-1">
<mat-label translate>edge.edge-secret</mat-label>
<input matInput formControlName="secret">
</mat-form-field>
<button mat-icon-button type="button" ngxClipboard (cbOnSuccess)="onEdgeInfoCopied('secret')"
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>edge.edge-secret</mat-label>
<input matInput formControlName="secret">
</mat-form-field>
<button mat-icon-button type="button" ngxClipboard
(cbOnSuccess)="onEdgeInfoCopied('secret')"
[cbContent]="entityForm?.get('secret').value"
matTooltip="{{'edge.copy-edge-secret' | translate}}">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
</button>
</div>
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>edge.label</mat-label>
<input matInput formControlName="label">
<mat-error *ngIf="entityForm.get('label').hasError('maxlength')">
{{ 'edge.label-max-length' | translate }}
</mat-error>
@if (entityForm.get('label').hasError('maxlength')) {
<mat-error>{{ 'edge.label-max-length' | translate }}</mat-error>
}
</mat-form-field>
<div formGroupName="additionalInfo" class="flex flex-col">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>edge.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

43
ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html

@ -64,7 +64,7 @@
</div>
</div>
<div class="mat-padding flex flex-col">
<mat-form-field class="mat-block"
<mat-form-field class="mat-block" appearance="outline"
[class.!hidden]="isEdit || !isAssignedToCustomer(entity)
|| entity?.customerIsPublic || entityViewScope !== 'tenant'">
<mat-label translate>entity-view.assignedToCustomer</mat-label>
@ -75,29 +75,30 @@
{{ 'entity-view.entity-view-public' | translate }}
</div>
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>entity-view.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'entity-view.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'entity-view.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'entity-view.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'entity-view.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<tb-entity-subtype-autocomplete
formControlName="type"
appearance="outline"
[required]="true"
[entityType]="entityType.ENTITY_VIEW"
>
[entityType]="entityType.ENTITY_VIEW">
</tb-entity-subtype-autocomplete>
<section class="flex flex-col">
<label translate class="tb-title no-padding">entity-view.target-entity</label>
<label translate class="tb-title">entity-view.target-entity</label>
<tb-entity-select class="flex-1"
[required]="true"
[allowedEntityTypes]="allowedEntityTypes"
formControlName="entityId">
formControlName="entityId"
appearance="outline">
</tb-entity-select>
</section>
<div class="mat-accordion-container" formGroupName="keys">
@ -114,21 +115,24 @@
[entityId]="selectedEntityId | async"
formControlName="cs"
keysText="entity-view.client-attributes-placeholder"
[dataKeyType]="dataKeyType.attribute">
[dataKeyType]="dataKeyType.attribute"
appearance="outline">
</tb-entity-keys-list>
<label translate class="tb-title no-padding">entity-view.shared-attributes</label>
<tb-entity-keys-list
[entityId]="selectedEntityId | async"
formControlName="sh"
keysText="entity-view.shared-attributes-placeholder"
[dataKeyType]="dataKeyType.attribute">
[dataKeyType]="dataKeyType.attribute"
appearance="outline">
</tb-entity-keys-list>
<label translate class="tb-title no-padding">entity-view.server-attributes</label>
<tb-entity-keys-list
[entityId]="selectedEntityId | async"
formControlName="ss"
keysText="entity-view.server-attributes-placeholder"
[dataKeyType]="dataKeyType.attribute">
[dataKeyType]="dataKeyType.attribute"
appearance="outline">
</tb-entity-keys-list>
</mat-expansion-panel>
<mat-expansion-panel [expanded]="true">
@ -143,7 +147,8 @@
[entityId]="selectedEntityId | async"
formControlName="timeseries"
keysText="entity-view.timeseries-placeholder"
[dataKeyType]="dataKeyType.timeseries">
[dataKeyType]="dataKeyType.timeseries"
appearance="outline">
</tb-entity-keys-list>
</mat-expansion-panel>
</mat-accordion>
@ -152,14 +157,16 @@
dateText="{{ 'entity-view.start-ts' | translate }}"
[maxDate]="maxStartTimeMs | async"
formControlName="startTimeMs"
appearance="outline"
></tb-datetime>
<tb-datetime class="block"
dateText="{{ 'entity-view.end-ts' | translate }}"
[minDate]="minEndTimeMs | async"
formControlName="endTimeMs"
appearance="outline"
></tb-datetime>
<div formGroupName="additionalInfo">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>entity-view.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

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

@ -109,7 +109,7 @@ export class MobileAppComponent extends EntityComponent<MobileApp> {
.addValidators(Validators.required);
form.get('storeInfo.appId').addValidators(Validators.required);
} else {
form.get('storeInfo.storeLink').clearValidators();
form.get('storeInfo.storeLink').removeValidators(Validators.required);
form.get('storeInfo.sha256CertFingerprints').removeValidators(Validators.required);
form.get('storeInfo.appId').removeValidators(Validators.required);
}

150
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html

@ -22,38 +22,38 @@
<mat-slide-toggle class="whitespace-nowrap xs:!hidden" [formControl]="showHiddenPages">{{ 'mobile.show-hidden-pages' | translate }}</mat-slide-toggle>
</div>
<span class="flex-1"></span>
<button *ngIf="!readonly" class="tb-mobile-layout-button lt-lg:!hidden"
mat-stroked-button
color="primary"
(click)="hideAll()">
<tb-icon matButtonIcon>visibility_off</tb-icon>
<div tbTruncateWithTooltip>{{ 'mobile.hide-all-pages' | translate }}</div>
</button>
<button *ngIf="!readonly" class="tb-mobile-layout-button lt-lg:!hidden"
mat-stroked-button
color="primary"
(click)="resetToDefault()">
<tb-icon matButtonIcon>mdi:broom</tb-icon>
<div tbTruncateWithTooltip>{{ 'mobile.reset-to-default-pages' | translate }}</div>
</button>
<button *ngIf="!readonly"
type="button"
mat-stroked-button
class="tb-box-button gt-md:!hidden"
matTooltip="{{ 'mobile.hide-all-pages' | translate }}"
matTooltipPosition="above"
(click)="hideAll()">
<tb-icon color="primary" matButtonIcon>visibility_off</tb-icon>
</button>
<button *ngIf="!readonly"
type="button"
mat-stroked-button
class="tb-box-button gt-md:!hidden"
matTooltip="{{ 'mobile.reset-to-default-pages' | translate }}"
matTooltipPosition="above"
(click)="resetToDefault()">
<tb-icon color="primary" matButtonIcon>mdi:broom</tb-icon>
</button>
@if (!readonly) {
<button class="tb-mobile-layout-button lt-lg:!hidden"
mat-stroked-button
color="primary"
(click)="hideAll()">
<tb-icon matButtonIcon>visibility_off</tb-icon>
<div tbTruncateWithTooltip>{{ 'mobile.hide-all-pages' | translate }}</div>
</button>
<button class="tb-mobile-layout-button lt-lg:!hidden"
mat-stroked-button
color="primary"
(click)="resetToDefault()">
<tb-icon matButtonIcon>mdi:broom</tb-icon>
<div tbTruncateWithTooltip>{{ 'mobile.reset-to-default-pages' | translate }}</div>
</button>
<button type="button"
mat-stroked-button
class="tb-box-button gt-md:!hidden"
matTooltip="{{ 'mobile.hide-all-pages' | translate }}"
matTooltipPosition="above"
(click)="hideAll()">
<tb-icon color="primary" matButtonIcon>visibility_off</tb-icon>
</button>
<button type="button"
mat-stroked-button
class="tb-box-button gt-md:!hidden"
matTooltip="{{ 'mobile.reset-to-default-pages' | translate }}"
matTooltipPosition="above"
(click)="resetToDefault()">
<tb-icon color="primary" matButtonIcon>mdi:broom</tb-icon>
</button>
}
<!-- <div tb-help="mobileLayout"></div>-->
</div>
<div #mobilePagesContainer class="tb-drop-list tb-mobile-items-container"
@ -61,45 +61,55 @@
cdkDropListOrientation="vertical"
(cdkDropListDropped)="mobileItemDrop($event)"
[cdkDropListDisabled]="!dragEnabled">
<div class="tb-mobile-item-row-container" *ngFor="let menuItemControl of visibleMobilePagesControls(); trackBy: trackByMenuItem;
let $index = index; last as isLast;">
<div cdkDrag [cdkDragDisabled]="!dragEnabled"
class="tb-draggable tb-mobile-item-row">
<div *ngIf="isCustom(menuItemControl)" class="tb-mobile-item-label">
{{ 'mobile.custom-page' | translate }}
@for (menuItemControl of visibleMobilePagesControls(); track trackByMenuItem($index, menuItemControl);) {
<div class="tb-mobile-item-row-container">
<div cdkDrag [cdkDragDisabled]="!dragEnabled"
class="tb-draggable tb-mobile-item-row">
@if (isCustom(menuItemControl)) {
<div class="tb-mobile-item-label">
{{ 'mobile.custom-page' | translate }}
</div>
}
@if (dragEnabled) {
<button cdkDragHandle
mat-icon-button
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above"
class="tb-mat-40 tb-drag-handle">
<tb-icon>drag_indicator</tb-icon>
</button>
}
<tb-mobile-menu-item-row style="flex: 1; min-width: 0;"
[formControl]="menuItemControl"
[maxIconNameBlockWidth]="maxIconNameBlockWidth"
[hideItems]="hideItems$"
(pageRemoved)="removeCustomPage($index)">
</tb-mobile-menu-item-row>
</div>
<button cdkDragHandle *ngIf="dragEnabled"
mat-icon-button
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above"
class="tb-mat-40 tb-drag-handle">
<tb-icon>drag_indicator</tb-icon>
</button>
<tb-mobile-menu-item-row style="flex: 1; min-width: 0;"
[formControl]="menuItemControl"
[maxIconNameBlockWidth]="maxIconNameBlockWidth"
[hideItems]="hideItems$"
(pageRemoved)="removeCustomPage($index)">
</tb-mobile-menu-item-row>
@if (!$last && !readonly) {
<button (click)="addCustomMobilePage($index)"
class="tb-add-mobile-item-button"
[class.divider]="showMenuDivider($index)"
mat-button
matTooltip="{{ 'mobile.add-specific-page' | translate }}"
matTooltipPosition="above"
color="primary">
<div class="tb-add-mobile-label-container tb-primary-fill">
<mat-icon class="tb-mat-18" color="primary">add</mat-icon>
@if (showMenuDivider($index)) {
<span class="tb-mobile-divider-label">{{ getDividerLabel($index) | translate }}</span>
}
</div>
</button>
}
</div>
<button *ngIf="!isLast && !readonly"
(click)="addCustomMobilePage($index)"
class="tb-add-mobile-item-button"
[class.divider]="showMenuDivider($index)"
mat-button
matTooltip="{{ 'mobile.add-specific-page' | translate }}"
matTooltipPosition="above"
color="primary">
<div class="tb-add-mobile-label-container tb-primary-fill">
<mat-icon class="tb-mat-18" color="primary">add</mat-icon>
<span class="tb-mobile-divider-label" *ngIf="showMenuDivider($index)">{{ getDividerLabel($index) | translate }}</span>
</div>
</button>
</div>
}
</div>
<button *ngIf="!readonly" (click)="addCustomMobilePage()"
mat-button color="primary" class="tb-primary-fill" style="min-height: 52px;">
<mat-icon color="primary">add</mat-icon>
<span translate>mobile.add-specific-page</span>
</button>
@if (!readonly) {
<button (click)="addCustomMobilePage()"
mat-button color="primary" class="tb-primary-fill" style="min-height: 52px;">
<mat-icon color="primary">add</mat-icon>
<span translate>mobile.add-specific-page</span>
</button>
}
</div>

109
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html

@ -34,58 +34,67 @@
<div class="tb-mobile-page-item-input-block xs:!hidden">
<mat-form-field class="tb-suffix-absolute flex" appearance="outline" subscriptSizing="dynamic">
<input matInput [required]="isCustomMenuItem" formControlName="label" placeholder="{{ itemNamePlaceholder }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-required' | translate }}"
*ngIf="mobilePageRowForm.get('label').hasError('required') && mobilePageRowForm.get('label').touched"
class="tb-error">
warning
</mat-icon>
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-cannot-contain-only-spaces' | translate }}"
*ngIf="mobilePageRowForm.get('label').hasError('pattern') && mobilePageRowForm.get('label').touched"
class="tb-error">
warning
</mat-icon>
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-max-length' | translate }}"
*ngIf="mobilePageRowForm.get('label').hasError('maxlength') && mobilePageRowForm.get('label').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
<mat-form-field *ngIf="isCustomMenuItem"
class="tb-mobile-page-item-info flex" appearance="outline" subscriptSizing="dynamic">
<input matInput readonly [placeholder]="itemInfo">
@if (mobilePageRowForm.get('label').hasError('required') && mobilePageRowForm.get('label').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-required' | translate }}"
class="tb-error">
warning
</mat-icon>
}
@if (mobilePageRowForm.get('label').hasError('pattern') && mobilePageRowForm.get('label').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-cannot-contain-only-spaces' | translate }}"
class="tb-error">
warning
</mat-icon>
}
@if (mobilePageRowForm.get('label').hasError('maxlength') && mobilePageRowForm.get('label').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-max-length' | translate }}"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
@if (isCustomMenuItem) {
<mat-form-field class="tb-mobile-page-item-info flex" appearance="outline" subscriptSizing="dynamic">
<input matInput readonly [placeholder]="itemInfo">
</mat-form-field>
}
</div>
</div>
<button *ngIf="!disabled"
type="button"
mat-icon-button
#editButton
(click)="edit($event, editButton)"
matTooltip="{{ 'mobile.edit-page' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button *ngIf="!disabled && isDefaultMenuItem"
[disabled]="!isCleanupEnabled"
matTooltip="{{ 'mobile.reset-to-page-default' | translate }}"
matTooltipPosition="above"
(click)="cleanup()">
<tb-icon>mdi:broom</tb-icon>
</button>
<button mat-icon-button *ngIf="!disabled && isCustomMenuItem"
matTooltip="{{ 'mobile.delete-page' | translate }}"
matTooltipPosition="above"
(click)="delete()">
<mat-icon>delete</mat-icon>
</button>
@if (!disabled) {
<button type="button"
mat-icon-button
#editButton
(click)="edit($event, editButton)"
matTooltip="{{ 'mobile.edit-page' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
@if (isDefaultMenuItem) {
<button mat-icon-button
[disabled]="!isCleanupEnabled"
matTooltip="{{ 'mobile.reset-to-page-default' | translate }}"
matTooltipPosition="above"
(click)="cleanup()">
<tb-icon>mdi:broom</tb-icon>
</button>
}
@if (isCustomMenuItem) {
<button mat-icon-button
matTooltip="{{ 'mobile.delete-page' | translate }}"
matTooltipPosition="above"
(click)="delete()">
<mat-icon>delete</mat-icon>
</button>
}
}
</div>
</div>

39
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html

@ -25,9 +25,12 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div *ngIf="!(isLoading$ | async)"></div>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate"></mat-progress-bar>
}
@if (!(isLoading$ | async)) {
<div></div>
}
<div mat-dialog-content class="xs:!p-0">
<mat-horizontal-stepper [linear]="isAdd" labelPosition="end" #addMobileBundle [orientation]="(stepperOrientation | async)"
(selectionChange)="changeStep($event)">
@ -41,15 +44,21 @@
<mat-label translate>mobile.title</mat-label>
<input matInput required formControlName="title">
<mat-hint> </mat-hint>
<mat-error *ngIf="bundlesForms.get('title').hasError('required')">
{{ 'mobile.title-required' | translate }}
</mat-error>
<mat-error *ngIf="bundlesForms.get('title').hasError('pattern')">
{{ 'mobile.title-cannot-contain-only-spaces' | translate }}
</mat-error>
<mat-error *ngIf="bundlesForms.get('title').hasError('maxlength')">
{{ 'mobile.title-max-length' | translate }}
</mat-error>
@if (bundlesForms.get('title').hasError('required')) {
<mat-error>
{{ 'mobile.title-required' | translate }}
</mat-error>
}
@if (bundlesForms.get('title').hasError('pattern')) {
<mat-error>
{{ 'mobile.title-cannot-contain-only-spaces' | translate }}
</mat-error>
}
@if (bundlesForms.get('title').hasError('maxlength')) {
<mat-error>
{{ 'mobile.title-max-length' | translate }}
</mat-error>
}
</mat-form-field>
<tb-entity-autocomplete
allowCreateNew
@ -106,8 +115,10 @@
</mat-horizontal-stepper>
</div>
<div mat-dialog-actions>
<button mat-stroked-button *ngIf="selectedIndex > 0"
(click)="backStep()">{{ 'action.back' | translate }}</button>
@if (selectedIndex > 0) {
<button mat-stroked-button
(click)="backStep()">{{ 'action.back' | translate }}</button>
}
<span class="flex-1"></span>
<button mat-raised-button
color="primary"

257
ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html

@ -26,134 +26,153 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="(isLoading$ | async) === false"></div>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate"></mat-progress-bar>
} @else {
<div style="height: 4px;"></div>
}
<div mat-dialog-content>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="targetNotificationForm.get('name').hasError('required')">
{{ 'notification.name-required' | translate }}
</mat-error>
@if (targetNotificationForm.get('name').hasError('required')) {
<mat-error>{{ 'notification.name-required' | translate }}</mat-error>
}
</mat-form-field>
<section formGroupName="configuration">
<label class="tb-title">{{ 'notification.type' | translate }}</label>
<mat-radio-group formControlName="type">
<mat-radio-button *ngFor="let notificationTargetType of notificationTargetTypes" [value]="notificationTargetType">
<div class="flex flex-col">
<div>{{ notificationTargetTypeTranslationMap.get(notificationTargetType) | translate }}</div>
</div>
</mat-radio-button>
</mat-radio-group>
<section formGroupName="usersFilter"
*ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetType.PLATFORM_USERS">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>notification.recipient-type.user-filters</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let type of notificationTargetConfigTypes" [value]="type">
{{ notificationTargetConfigTypeInfoMap.get(type).name | translate }}
</mat-option>
</mat-select>
<mat-hint>{{ notificationTargetConfigTypeInfoMap.get(targetNotificationForm.get('configuration.usersFilter.type').value).hint | translate }}</mat-hint>
</mat-form-field>
<ng-container [ngSwitch]="targetNotificationForm.get('configuration.usersFilter.type').value">
<ng-container *ngSwitchCase="notificationTargetConfigType.TENANT_ADMINISTRATORS">
<section *ngIf="isSysAdmin()">
<div class="flex flex-1 items-center justify-center">
<tb-toggle-select class="tb-notification-tenant-group" appearance="fill"
formControlName="filterByTenants">
<tb-toggle-option [value]="true">{{ 'tenant.tenant' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="false">{{ 'tenant-profile.tenant-profile' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<ng-container *ngIf="targetNotificationForm.get('configuration.usersFilter.filterByTenants').value; else tenantProfiles">
<tb-entity-list
formControlName="tenantsIds"
syncIdsWithDB
subscriptSizing="dynamic"
labelText="{{ 'tenant.tenants' | translate }}"
placeholderText="{{ 'tenant.tenants' | translate }}"
hint="{{ 'notification.tenants-list-rule-hint' | translate }}"
[entityType]="entityType.TENANT">
</tb-entity-list>
</ng-container>
<ng-template #tenantProfiles>
<tb-entity-list
formControlName="tenantProfilesIds"
syncIdsWithDB
subscriptSizing="dynamic"
labelText="{{ 'tenant-profile.tenant-profiles' | translate }}"
placeholderText="{{ 'tenant-profile.tenant-profiles' | translate }}"
hint="{{ 'notification.tenant-profiles-list-rule-hint' | translate }}"
[entityType]="entityType.TENANT_PROFILE">
</tb-entity-list>
</ng-template>
</section>
</ng-container>
<ng-container *ngSwitchCase="notificationTargetConfigType.USER_LIST">
<tb-entity-list
required
syncIdsWithDB
formControlName="usersIds"
[entityType]="entityType.USER"
labelText="{{ 'user.user-list' | translate }}"
placeholderText="{{ 'user.user' | translate }}"
requiredText="{{ 'user.user-list-required' | translate }}">
</tb-entity-list>
</ng-container>
<ng-container *ngSwitchCase="notificationTargetConfigType.CUSTOMER_USERS">
<tb-entity-autocomplete
required
formControlName="customerId"
[entityType]="entityType.CUSTOMER">
</tb-entity-autocomplete>
</ng-container>
</ng-container>
</section>
<section *ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetType.SLACK">
<label class="tb-title">{{ 'notification.slack-chanel-type' | translate }}</label>
<mat-radio-group formControlName="conversationType">
<mat-radio-button *ngFor="let slackChanelType of slackChanelTypes" [value]="slackChanelType">
{{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }}
@for (notificationTargetType of notificationTargetTypes; track notificationTargetType) {
<mat-radio-button [value]="notificationTargetType">
<div class="flex flex-col">
<div>{{ notificationTargetTypeTranslationMap.get(notificationTargetType) | translate }}</div>
</div>
</mat-radio-button>
</mat-radio-group>
<tb-slack-conversation-autocomplete
formControlName="conversation" required
[slackChanelType]="targetNotificationForm.get('configuration.conversationType').value">
</tb-slack-conversation-autocomplete>
</section>
<section *ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetType.MICROSOFT_TEAMS">
<section class="tb-form-row" style="margin-bottom: 16px">
<mat-slide-toggle class="mat-slide margin" formControlName="useOldApi">
{{ "notification.use-old-api" | translate }}
</mat-slide-toggle>
<a mat-icon-button
href="https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/"
target="_blank"
matTooltip="{{ 'notification.use-deprecated-webhook-connectors' | translate }}"
matTooltipPosition="above"
class="tb-mat-20">
<mat-icon>open_in_new</mat-icon>
</a>
}
</mat-radio-group>
@if (targetNotificationForm.get('configuration.type').value === notificationTargetType.PLATFORM_USERS) {
<section formGroupName="usersFilter">
<mat-form-field class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>notification.recipient-type.user-filters</mat-label>
<mat-select formControlName="type">
@for (type of notificationTargetConfigTypes; track type) {
<mat-option [value]="type">
{{ notificationTargetConfigTypeInfoMap.get(type).name | translate }}
</mat-option>
}
</mat-select>
<mat-hint>{{ notificationTargetConfigTypeInfoMap.get(targetNotificationForm.get('configuration.usersFilter.type').value).hint | translate }}</mat-hint>
</mat-form-field>
@switch (targetNotificationForm.get('configuration.usersFilter.type').value) {
@case (notificationTargetConfigType.TENANT_ADMINISTRATORS) {
@if (isSysAdmin()) {
<section>
<div class="flex flex-1 items-center justify-center">
<tb-toggle-select class="tb-notification-tenant-group" appearance="fill"
formControlName="filterByTenants">
<tb-toggle-option [value]="true">{{ 'tenant.tenant' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="false">{{ 'tenant-profile.tenant-profile' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
@if (targetNotificationForm.get('configuration.usersFilter.filterByTenants').value) {
<tb-entity-list
formControlName="tenantsIds"
syncIdsWithDB
appearance="outline"
subscriptSizing="dynamic"
labelText="{{ 'tenant.tenants' | translate }}"
placeholderText="{{ 'tenant.tenants' | translate }}"
hint="{{ 'notification.tenants-list-rule-hint' | translate }}"
[entityType]="entityType.TENANT">
</tb-entity-list>
} @else {
<tb-entity-list
formControlName="tenantProfilesIds"
syncIdsWithDB
appearance="outline"
subscriptSizing="dynamic"
labelText="{{ 'tenant-profile.tenant-profiles' | translate }}"
placeholderText="{{ 'tenant-profile.tenant-profiles' | translate }}"
hint="{{ 'notification.tenant-profiles-list-rule-hint' | translate }}"
[entityType]="entityType.TENANT_PROFILE">
</tb-entity-list>
}
</section>
}
}
@case (notificationTargetConfigType.USER_LIST) {
<tb-entity-list
required
syncIdsWithDB
appearance="outline"
formControlName="usersIds"
[entityType]="entityType.USER"
labelText="{{ 'user.user-list' | translate }}"
placeholderText="{{ 'user.user' | translate }}"
requiredText="{{ 'user.user-list-required' | translate }}">
</tb-entity-list>
}
@case (notificationTargetConfigType.CUSTOMER_USERS) {
<tb-entity-autocomplete
required
appearance="outline"
formControlName="customerId"
[entityType]="entityType.CUSTOMER">
</tb-entity-autocomplete>
}
}
</section>
}
@if (targetNotificationForm.get('configuration.type').value === notificationTargetType.SLACK) {
<section>
<label class="tb-title">{{ 'notification.slack-chanel-type' | translate }}</label>
<mat-radio-group formControlName="conversationType">
@for (slackChanelType of slackChanelTypes; track slackChanelType) {
<mat-radio-button [value]="slackChanelType">
{{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }}
</mat-radio-button>
}
</mat-radio-group>
<tb-slack-conversation-autocomplete
formControlName="conversation" required
appearance="outline"
[slackChanelType]="targetNotificationForm.get('configuration.conversationType').value">
</tb-slack-conversation-autocomplete>
</section>
}
@if (targetNotificationForm.get('configuration.type').value === notificationTargetType.MICROSOFT_TEAMS) {
<section>
<section class="tb-form-row" style="margin-bottom: 16px">
<mat-slide-toggle class="mat-slide margin" formControlName="useOldApi">
{{ "notification.use-old-api" | translate }}
</mat-slide-toggle>
<a mat-icon-button
href="https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/"
target="_blank"
matTooltip="{{ 'notification.use-deprecated-webhook-connectors' | translate }}"
matTooltipPosition="above"
class="tb-mat-20">
<mat-icon>open_in_new</mat-icon>
</a>
</section>
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ (targetNotificationForm.get('configuration.useOldApi').value ?
'notification.webhook-url' : 'notification.workflow-url') | translate }}</mat-label>
<input matInput formControlName="webhookUrl">
@if (targetNotificationForm.get('configuration.webhookUrl').hasError('required')) {
<mat-error>{{ (targetNotificationForm.get('configuration.useOldApi').value ? 'notification.webhook-url-required' : 'notification.workflow-url-required') | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.channel-name</mat-label>
<input matInput formControlName="channelName">
@if (targetNotificationForm.get('configuration.channelName').hasError('required')) {
<mat-error>{{ 'notification.channel-name-required' | translate }}</mat-error>
}
</mat-form-field>
</section>
<mat-form-field class="mat-block">
<mat-label>{{ (targetNotificationForm.get('configuration.useOldApi').value ?
'notification.webhook-url' : 'notification.workflow-url') | translate }}</mat-label>
<input matInput formControlName="webhookUrl">
<mat-error *ngIf="targetNotificationForm.get('configuration.webhookUrl').hasError('required')">
{{ (targetNotificationForm.get('configuration.useOldApi').value ? 'notification.webhook-url-required' : 'notification.workflow-url-required') | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.channel-name</mat-label>
<input matInput formControlName="channelName">
<mat-error *ngIf="targetNotificationForm.get('configuration.channelName').hasError('required')">
{{ 'notification.channel-name-required' | translate }}
</mat-error>
</mat-form-field>
</section>
<mat-form-field class="mat-block">
}
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

17
ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html

@ -17,25 +17,28 @@
-->
<form [formGroup]="escalationFormGroup" class="flex flex-col">
<div class="escalation flex flex-row items-center justify-center xs:flex-col xs:items-stretch xs:justify-start">
<div class="flex-1" *ngIf="systemEscalation; else selectTime" translate>notification.first-recipient</div>
<ng-template #selectTime>
@if (systemEscalation) {
<div class="flex-1" translate>notification.first-recipient</div>
} @else {
<div class="flex flex-1 flex-row items-center justify-center xs:flex-col">
<span translate>notification.after</span>
<tb-timeinterval
appearance="outline"
subscriptSizing="dynamic"
formControlName="delayInSec"
min="60000"
max="604800000"
disabledAdvanced></tb-timeinterval>
[min]="60000"
[max]="604800000"
disabledAdvanced=""></tb-timeinterval>
<span class="escalation-notify flex-1" translate>notification.notify</span>
</div>
</ng-template>
}
<tb-entity-list
required
class="flex-full xs:max-h-60% gt-xs:max-w-60%"
formControlName="targets"
appearance="outline"
subscriptSizing="dynamic"
entityType="{{ entityType.NOTIFICATION_TARGET }}"
[entityType]="entityType.NOTIFICATION_TARGET"
subType="{{ notificationType.ALARM }}"
[placeholderText]="escalationFormGroup.get('targets').value ? '' : ('notification.add-recipient' | translate)">
<button #createTargetButton

62
ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.html

@ -16,32 +16,38 @@
-->
<div class="flex flex-col">
<div class="tb-tenant-profile-queues flex flex-row items-center"
*ngFor="let escalationControl of escalationsFormArray.controls;
let $index = index; last as isLast;"
[class.pb-2]="!isLast">
<tb-escalation-form class="flex-1"
[formControl]="escalationControl"
[systemEscalation]="$index === 0">
</tb-escalation-form>
<span *ngIf="$index === 0" style="width: 48px;"></span>
<button *ngIf="!($index === 0) && !disabled" mat-icon-button style="min-width: 40px;"
type="button"
(click)="removeEscalation($index)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
<div *ngIf="!escalationsFormArray.controls.length">
<span translate
class="tb-prompt flex items-center justify-center">notification.no-rule</span>
</div>
<div *ngIf="!disabled" style="padding: 16px 0;">
<button mat-raised-button color="primary"
type="button"
(click)="addEscalation()">
<span translate>notification.add-stage</span>
</button>
</div>
@for (escalationControl of escalationsFormArray.controls; track $index; let isLast = $last) {
<div class="tb-tenant-profile-queues flex flex-row items-center" [class.pb-2]="!isLast">
<tb-escalation-form class="flex-1"
[formControl]="escalationControl"
[systemEscalation]="$index === 0">
</tb-escalation-form>
@if ($index === 0) {
<span style="width: 48px;"></span>
}
@if (!($index === 0) && !disabled) {
<button mat-icon-button style="min-width: 40px;"
type="button"
(click)="removeEscalation($index)"
matTooltip="{{ 'action.remove' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@if (!escalationsFormArray.controls.length) {
<div>
<span translate class="tb-prompt flex items-center justify-center">notification.no-rule</span>
</div>
}
@if (!disabled) {
<div style="padding: 16px 0;">
<button mat-raised-button color="primary"
type="button"
(click)="addEscalation()">
<span translate>notification.add-stage</span>
</button>
</div>
}
</div>

1167
ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html

File diff suppressed because it is too large

1
ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss

@ -112,6 +112,7 @@
max-height: 100%;
width: 100%;;
overflow-y: auto;
padding-top: 4px;
scrollbar-gutter: stable;
@media #{$mat-gt-sm} {
min-width: 500px;

278
ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html

@ -44,17 +44,18 @@
<tb-toggle-option [value]="true">{{ 'notification.use-template' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div *ngIf="notificationRequestForm.get('useTemplate').value; else scratchTemplate">
<tb-template-autocomplete
required allowCreate allowEdit
formControlName="templateId"
[notificationTypes]="notificationType.GENERAL">
</tb-template-autocomplete>
<ng-container *ngTemplateOutlet="recipientsList"></ng-container>
</div>
<ng-template #scratchTemplate>
<ng-container *ngTemplateOutlet="recipientsList"></ng-container>
<section formGroupName="template">
<div>
@if (notificationRequestForm.get('useTemplate').value) {
<tb-template-autocomplete
required allowCreate allowEdit
appearance="outline"
formControlName="templateId"
[notificationTypes]="notificationType.GENERAL">
</tb-template-autocomplete>
<ng-container *ngTemplateOutlet="recipientsList"></ng-container>
} @else {
<ng-container *ngTemplateOutlet="recipientsList"></ng-container>
<section formGroupName="template">
<section formGroupName="configuration">
<div class="delivery-title">
<div>
@ -101,16 +102,18 @@
</section>
</section>
</section>
</ng-template>
}
</div>
<ng-template #recipientsList>
<tb-entity-list
syncIdsWithDB
required
formControlName="targets"
appearance="outline"
labelText="{{ 'notification.recipients' | translate }}"
placeholderText="{{ 'notification.recipient' | translate }}"
requiredText="{{ 'notification.recipients-required' | translate }}"
entityType="{{ entityType.NOTIFICATION_TARGET }}"
[entityType]="entityType.NOTIFICATION_TARGET"
subType="{{ notificationType.GENERAL }}">
<button #createTargetButton
mat-button color="primary" matSuffix
@ -123,129 +126,156 @@
<mat-slide-toggle formControlName="enabled" class="toggle">
{{ 'notification.scheduler-later' | translate }}
</mat-slide-toggle>
<div *ngIf="notificationRequestForm.get('additionalConfig.enabled').value" class="flex flex-col">
<tb-timezone-select userTimezoneByDefault
required
formControlName="timezone">
</tb-timezone-select>
<mat-form-field>
<mat-label translate>notification.time</mat-label>
<mat-datetimepicker-toggle [for]="startTimePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #startTimePicker type="datetime" openOnFocus="true"></mat-datetimepicker>
<input required matInput class="flex-1" formControlName="time" [min]="minDate()" [max]="maxDate()" [matDatetimepicker]="startTimePicker">
</mat-form-field>
</div>
@if (notificationRequestForm.get('additionalConfig.enabled').value) {
<div class="flex flex-col">
<tb-timezone-select userTimezoneByDefault
required
appearance="outline"
formControlName="timezone">
</tb-timezone-select>
<mat-form-field appearance="outline">
<mat-label translate>notification.time</mat-label>
<mat-datetimepicker-toggle [for]="startTimePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #startTimePicker type="datetime" openOnFocus="true"></mat-datetimepicker>
<input required matInput class="flex-1" formControlName="time" [min]="minDate()" [max]="maxDate()" [matDatetimepicker]="startTimePicker">
</mat-form-field>
</div>
}
</section>
</form>
</mat-step>
<mat-step *ngIf="!notificationRequestForm.get('useTemplate').value"
[stepControl]="notificationTemplateConfigurationForm" #composeStep=matStep>
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<ng-template matStepContent>
<form [formGroup]="notificationTemplateConfigurationForm">
<tb-template-configuration
notificationType="{{ notificationType.GENERAL }}"
[predefinedDeliveryMethodsTemplate]="deliveryConfiguration"
[interacted]="composeStep.interacted"
formControlName="deliveryMethodsTemplates">
</tb-template-configuration>
</form>
</ng-template>
</mat-step>
@if (!notificationRequestForm.get('useTemplate').value) {
<mat-step [stepControl]="notificationTemplateConfigurationForm" #composeStep=matStep>
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<ng-template matStepContent>
<form [formGroup]="notificationTemplateConfigurationForm">
<tb-template-configuration
[notificationType]="notificationType.GENERAL"
[predefinedDeliveryMethodsTemplate]="deliveryConfiguration"
[interacted]="composeStep.interacted"
formControlName="deliveryMethodsTemplates">
</tb-template-configuration>
</form>
</ng-template>
</mat-step>
}
<mat-step>
<ng-template matStepLabel>{{ 'notification.review' | translate }}</ng-template>
<div class="tb-loader" *ngIf="(isLoading$ | async) && !preview">
<mat-progress-spinner color="warn" mode="indeterminate"
strokeWidth="5">
</mat-progress-spinner>
</div>
<div *ngIf="preview" class="tb-form-panel no-padding no-border">
<section class="preview-group tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">supervisor_account</tb-icon>
<div class="tb-form-panel-title">{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}</div>
</div>
<div class="details-recipients" *ngIf="notificationRequestForm.get('targets').value?.length > 1">
<div *ngFor="let detail of preview.recipientsCountByTarget | keyvalue" class="details-recipient">
<span class="number">{{ detail.value }}</span>{{ detail.key }}
@if ((isLoading$ | async) && !preview) {
<div class="tb-loader">
<mat-progress-spinner color="warn" mode="indeterminate"
strokeWidth="5">
</mat-progress-spinner>
</div>
}
@if (preview) {
<div class="tb-form-panel no-padding no-border">
<section class="preview-group tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">supervisor_account</tb-icon>
<div class="tb-form-panel-title">{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}</div>
</div>
</div>
<mat-divider class="divider"></mat-divider>
<mat-chip-set>
<mat-chip-row class="tb-chip-row-ellipsis" *ngFor="let recipientTitle of preview.recipientsPreview">
<span>{{ recipientTitle }}</span>
</mat-chip-row>
</mat-chip-set>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.WEB?.enabled">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:bell-badge</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.web-preview</div>
</div>
<div class="web-preview">
<tb-notification preview [notification]="preview.processedTemplates.WEB"></tb-notification>
</div>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.MOBILE_APP?.enabled">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:cellphone-text</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.mobile-app-preview</div>
</div>
<div class="notification-content">
<div class="subject">{{ preview.processedTemplates.MOBILE_APP.subject }}</div>
<div>{{ preview.processedTemplates.MOBILE_APP.body }}</div>
</div>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.SMS?.enabled">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:message-processing</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.sms-preview</div>
</div>
<div class="notification-content">
{{ preview.processedTemplates.SMS.body }}
</div>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.EMAIL?.enabled">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:email</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.email-preview</div>
</div>
<div class="notification-content">
<div class="subject">{{ preview.processedTemplates.EMAIL.subject }}</div>
<mat-divider></mat-divider>
<editor [init]="tinyMceOptions" [initialValue]="preview.processedTemplates.EMAIL.body"></editor>
</div>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.SLACK?.enabled">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:slack</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.slack-preview</div>
</div>
<div class="notification-content">
{{ preview.processedTemplates.SLACK.body }}
</div>
</section>
<section class="preview-group notification tb-form-panel stroked no-gap" *ngIf="preview.processedTemplates.MICROSOFT_TEAMS?.enabled">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:microsoft-teams</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.microsoft-teams-preview</div>
</div>
<div class="notification-content mini" [style.background-color]="preview.processedTemplates.MICROSOFT_TEAMS?.themeColor">
<div class="subject">{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}</div>
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }}
@if (preview.processedTemplates.MICROSOFT_TEAMS.button?.enabled) {
<button mat-raised-button>{{ preview.processedTemplates.MICROSOFT_TEAMS.button?.text }}</button>
@if (notificationRequestForm.get('targets').value?.length > 1) {
<div class="details-recipients">
@for (detail of (preview.recipientsCountByTarget | keyvalue); track detail.key) {
<div class="details-recipient">
<span class="number">{{ detail.value }}</span>{{ detail.key }}
</div>
}
</div>
}
</div>
</section>
</div>
<mat-divider class="divider"></mat-divider>
<mat-chip-set>
@for (recipientTitle of preview.recipientsPreview; track recipientTitle) {
<mat-chip-row class="tb-chip-row-ellipsis">
<span>{{ recipientTitle }}</span>
</mat-chip-row>
}
</mat-chip-set>
</section>
@if (preview.processedTemplates.WEB?.enabled) {
<section class="preview-group notification tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:bell-badge</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.web-preview</div>
</div>
<div class="web-preview">
<tb-notification preview [notification]="preview.processedTemplates.WEB"></tb-notification>
</div>
</section>
}
@if (preview.processedTemplates.MOBILE_APP?.enabled) {
<section class="preview-group notification tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:cellphone-text</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.mobile-app-preview</div>
</div>
<div class="notification-content">
<div class="subject">{{ preview.processedTemplates.MOBILE_APP.subject }}</div>
<div>{{ preview.processedTemplates.MOBILE_APP.body }}</div>
</div>
</section>
}
@if (preview.processedTemplates.SMS?.enabled) {
<section class="preview-group notification tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:message-processing</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.sms-preview</div>
</div>
<div class="notification-content">
{{ preview.processedTemplates.SMS.body }}
</div>
</section>
}
@if (preview.processedTemplates.EMAIL?.enabled) {
<section class="preview-group notification tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:email</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.email-preview</div>
</div>
<div class="notification-content">
<div class="subject">{{ preview.processedTemplates.EMAIL.subject }}</div>
<mat-divider></mat-divider>
<editor [init]="tinyMceOptions" [initialValue]="preview.processedTemplates.EMAIL.body"></editor>
</div>
</section>
}
@if (preview.processedTemplates.SLACK?.enabled) {
<section class="preview-group notification tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:slack</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.slack-preview</div>
</div>
<div class="notification-content">
{{ preview.processedTemplates.SLACK.body }}
</div>
</section>
}
@if (preview.processedTemplates.MICROSOFT_TEAMS?.enabled) {
<section class="preview-group notification tb-form-panel stroked no-gap">
<div class="flex flex-row items-center justify-start gap-2">
<tb-icon class="tb-mat-18">mdi:microsoft-teams</tb-icon>
<div class="tb-form-panel-title" translate>notification.delivery-method.microsoft-teams-preview</div>
</div>
<div class="notification-content mini" [style.background-color]="preview.processedTemplates.MICROSOFT_TEAMS?.themeColor">
<div class="subject">{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}</div>
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }}
@if (preview.processedTemplates.MICROSOFT_TEAMS.button?.enabled) {
<button mat-raised-button>{{ preview.processedTemplates.MICROSOFT_TEAMS.button?.text }}</button>
}
</div>
</section>
}
</div>
}
</mat-step>
</mat-horizontal-stepper>
</div>
<mat-divider></mat-divider>
<div mat-dialog-actions class="tb-dialog-actions">
<button mat-stroked-button *ngIf="selectedIndex > 0"
(click)="backStep()">{{ 'action.back' | translate }}</button>
@if (selectedIndex > 0) {
<button mat-stroked-button (click)="backStep()">{{ 'action.back' | translate }}</button>
}
<span class="flex-1"></span>
<button mat-raised-button
color="primary"

1
ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss

@ -218,6 +218,7 @@
width: 100%;;
overflow-y: auto;
scrollbar-gutter: stable;
padding-top: 4px;
@media #{$mat-gt-sm} {
min-width: 500px;
}

79
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html

@ -29,58 +29,63 @@
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<div class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1" *ngIf="!hideButtonText">
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error
*ngIf="actionButtonConfigForm.get('text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
<mat-error
*ngIf="actionButtonConfigForm.get('text').hasError('maxlength')">
{{ 'notification.button-text-max-length' | translate :
@if (!hideButtonText) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
@if (actionButtonConfigForm.get('text').hasError('required')) {
<mat-error>{{ 'notification.button-text-required' | translate }}</mat-error>
}
@if (actionButtonConfigForm.get('text').hasError('maxlength')) {
<mat-error>{{ 'notification.button-text-max-length' | translate :
{length: actionButtonConfigForm.get('text').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
}}</mat-error>
}
</mat-form-field>
}
</div>
<div class="grid gt-sm:grid-cols-3 gt-sm:gap-4">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.action-type</mat-label>
<mat-select formControlName="linkType">
<mat-option *ngFor="let actionButtonLinkType of actionButtonLinkTypes"
[value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
@for (actionButtonLinkType of actionButtonLinkTypes; track actionButtonLinkType) {
<mat-option [value]="actionButtonLinkType">
{{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if(actionButtonConfigForm.get('linkType').value === actionButtonLinkType.LINK) {
<mat-form-field class="gt-sm:col-span-2">
@if (actionButtonConfigForm.get('linkType').value === actionButtonLinkType.LINK) {
<mat-form-field class="gt-sm:col-span-2" appearance="outline">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error
*ngIf="actionButtonConfigForm.get('link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
<mat-error
*ngIf="actionButtonConfigForm.get('link').hasError('maxlength')">
{{ 'notification.link-max-length' | translate :
{length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength}
}}
</mat-error>
@if (actionButtonConfigForm.get('link').hasError('required')) {
<mat-error>{{ 'notification.link-required' | translate }}</mat-error>
}
@if (actionButtonConfigForm.get('link').hasError('maxlength')) {
<mat-error>{{ 'notification.link-max-length' | translate :
{length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
} @else {
<tb-dashboard-autocomplete required formControlName="dashboardId">
<tb-dashboard-autocomplete
required
appearance="outline"
formControlName="dashboardId">
</tb-dashboard-autocomplete>
<tb-dashboard-state-autocomplete [dashboardId]="actionButtonConfigForm.get('dashboardId').value"
formControlName="dashboardState">
<tb-dashboard-state-autocomplete
appearance="outline"
[dashboardId]="actionButtonConfigForm.get('dashboardId').value"
formControlName="dashboardState">
</tb-dashboard-state-autocomplete>
}
</div>
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle"
*ngIf="actionButtonConfigForm.get('linkType').value === actionButtonLinkType.DASHBOARD">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
@if (actionButtonConfigForm.get('linkType').value === actionButtonLinkType.DASHBOARD) {
<mat-slide-toggle formControlName="setEntityIdInState" class="toggle">
{{ 'notification.set-entity-from-notification' | translate }}
</mat-slide-toggle>
}
</ng-template>
</mat-expansion-panel>
</div>

486
ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html

@ -18,7 +18,7 @@
<section class="tb-template-header">
<div class="tb-form-panel-title" translate>notification.customize-messages</div>
<div class="tb-form-panel-hint tb-hint-available-params">
<span class="content">{{ 'notification.input-fields-support-templatization' | translate}}</span>
<span class="content">{{ 'notification.input-fields-support-templatization' | translate }}</span>
<span tb-help-popup="{{ NotificationTemplateTypeTranslateMap.get(notificationType).helpId }}"
tb-help-popup-placement="bottom"
trigger-style="letter-spacing:0.25px;font-size:12px;"
@ -27,265 +27,235 @@
</div>
</section>
<section [formGroup]="templateConfigurationForm" class="tb-form-panel no-border no-padding">
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.WEB"
*ngIf="templateConfigurationForm.get('WEB.enabled').value">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.WEB) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="templateConfigurationForm.get('WEB.subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('WEB.subject').hasError('maxlength')">
{{'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('WEB.subject').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('WEB.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('WEB.body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('WEB.body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<section formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-material-icon-select asBoxInput
[color]="templateConfigurationForm.get('WEB.additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
@if (templateConfigurationForm.get('WEB.enabled').value) {
<section class="tb-form-panel tb-slide-toggle stroked" [formGroupName]="NotificationDeliveryMethod.WEB">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.WEB) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
@if (templateConfigurationForm.get('WEB.subject').hasError('required')) {
<mat-error>{{ 'notification.subject-required' | translate }}</mat-error>
}
@if (templateConfigurationForm.get('WEB.subject').hasError('maxlength')) {
<mat-error>{{ 'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('WEB.subject').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.message</mat-label>
<textarea matInput cdkTextareaAutosize cols="1" cdkAutosizeMinRows="1" formControlName="body"></textarea>
@if (templateConfigurationForm.get('WEB.body').hasError('required')) {
<mat-error>{{ 'notification.message-required' | translate }}</mat-error>
}
@if (templateConfigurationForm.get('WEB.body').hasError('maxlength')) {
<mat-error>{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('WEB.body').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
<section formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-material-icon-select asBoxInput
[color]="templateConfigurationForm.get('WEB.additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput formControlName="color"></tb-color-input>
</div>
</div>
</div>
<tb-notification-action-button-configuration
actionTitle="{{ 'notification.action-button' | translate }}"
formControlName="actionButtonConfig">
</tb-notification-action-button-configuration>
</section>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.MOBILE_APP"
*ngIf="templateConfigurationForm.get('MOBILE_APP.enabled').value">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.MOBILE_APP) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.subject').hasError('maxlength')">
{{ 'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('MOBILE_APP.subject').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('MOBILE_APP.body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('MOBILE_APP.body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<div formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-material-icon-select asBoxInput
[color]="templateConfigurationForm.get('MOBILE_APP.additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
<tb-notification-action-button-configuration
actionTitle="{{ 'notification.action-button' | translate }}"
formControlName="actionButtonConfig">
</tb-notification-action-button-configuration>
</section>
</ng-template>
</mat-expansion-panel>
</section>
}
@if (templateConfigurationForm.get('MOBILE_APP.enabled').value) {
<section class="tb-form-panel tb-slide-toggle stroked" [formGroupName]="NotificationDeliveryMethod.MOBILE_APP">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.MOBILE_APP) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
@if (templateConfigurationForm.get('MOBILE_APP.subject').hasError('required')) {
<mat-error>{{ 'notification.subject-required' | translate }}</mat-error>
}
@if (templateConfigurationForm.get('MOBILE_APP.subject').hasError('maxlength')) {
<mat-error>{{ 'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('MOBILE_APP.subject').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.message</mat-label>
<textarea matInput cdkTextareaAutosize cols="1" cdkAutosizeMinRows="1" formControlName="body"></textarea>
@if (templateConfigurationForm.get('MOBILE_APP.body').hasError('required')) {
<mat-error>{{ 'notification.message-required' | translate }}</mat-error>
}
@if (templateConfigurationForm.get('MOBILE_APP.body').hasError('maxlength')) {
<mat-error>{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('MOBILE_APP.body').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
<div formGroupName="additionalConfig" class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between" formGroupName="icon">
<mat-slide-toggle formControlName="enabled" class="mat-slide">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-material-icon-select asBoxInput
[color]="templateConfigurationForm.get('MOBILE_APP.additionalConfig.icon.color').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput formControlName="color"></tb-color-input>
</div>
</div>
<tb-notification-action-button-configuration
formControlName="onClick"
hideButtonText
[sliderHint]="notificationTapActionHint"
actionTitle="{{ 'notification.notification-tap-action' | translate }}">
</tb-notification-action-button-configuration>
</div>
<tb-notification-action-button-configuration
formControlName="onClick"
hideButtonText
[sliderHint]="notificationTapActionHint"
actionTitle="{{ 'notification.notification-tap-action' | translate }}">
</tb-notification-action-button-configuration>
</div>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.SMS"
*ngIf="templateConfigurationForm.get('SMS.enabled').value">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.SMS) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-hint></mat-hint>
<mat-error *ngIf="templateConfigurationForm.get('SMS.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('SMS.body').hasError('maxlength')">
{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('SMS.body').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.EMAIL"
*ngIf="templateConfigurationForm.get('EMAIL.enabled').value">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.EMAIL) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
<mat-error *ngIf="templateConfigurationForm.get('EMAIL.subject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
<mat-error *ngIf="templateConfigurationForm.get('EMAIL.subject').hasError('maxlength')">
{{'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('EMAIL.subject').getError('maxlength').requiredLength}
}}
</mat-error>
</mat-form-field>
<mat-label class="tb-title tb-required"
[class.tb-error]="(interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')"
translate>notification.message
</mat-label>
<editor [init]="tinyMceOptions" formControlName="body"></editor>
<mat-error class="tb-mat-error"
*ngIf="(interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.SLACK"
*ngIf="templateConfigurationForm.get('SLACK.enabled').value">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.SLACK) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('SLACK.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</section>
<section class="tb-form-panel tb-slide-toggle stroked"
[formGroupName]="NotificationDeliveryMethod.MICROSOFT_TEAMS"
*ngIf="templateConfigurationForm.get('MICROSOFT_TEAMS.enabled').value">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.MICROSOFT_TEAMS) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<textarea matInput
cdkTextareaAutosize
cols="1"
cdkAutosizeMinRows="1"
formControlName="body">
</textarea>
<mat-error *ngIf="templateConfigurationForm.get('MICROSOFT_TEAMS.body').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between">
<div translate>notification.theme-color</div>
<tb-color-input asBoxInput formControlName="themeColor"></tb-color-input>
</ng-template>
</mat-expansion-panel>
</section>
}
@if (templateConfigurationForm.get('SMS.enabled').value) {
<section class="tb-form-panel tb-slide-toggle stroked" [formGroupName]="NotificationDeliveryMethod.SMS">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.SMS) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>notification.message</mat-label>
<textarea matInput cdkTextareaAutosize cols="1" cdkAutosizeMinRows="1" formControlName="body"></textarea>
<mat-hint></mat-hint>
@if (templateConfigurationForm.get('SMS.body').hasError('required')) {
<mat-error>{{ 'notification.message-required' | translate }}</mat-error>
}
@if (templateConfigurationForm.get('SMS.body').hasError('maxlength')) {
<mat-error>{{ 'notification.message-max-length' | translate :
{length: templateConfigurationForm.get('SMS.body').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</section>
}
@if (templateConfigurationForm.get('EMAIL.enabled').value) {
<section class="tb-form-panel tb-slide-toggle stroked" [formGroupName]="NotificationDeliveryMethod.EMAIL">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.EMAIL) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
@if (templateConfigurationForm.get('EMAIL.subject').hasError('required')) {
<mat-error>{{ 'notification.subject-required' | translate }}</mat-error>
}
@if (templateConfigurationForm.get('EMAIL.subject').hasError('maxlength')) {
<mat-error>{{ 'notification.subject-max-length' | translate :
{length: templateConfigurationForm.get('EMAIL.subject').getError('maxlength').requiredLength}
}}</mat-error>
}
</mat-form-field>
<mat-label class="tb-title tb-required"
[class.tb-error]="(interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')"
translate>notification.message
</mat-label>
<editor [init]="tinyMceOptions" formControlName="body"></editor>
@if ((interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')) {
<mat-error class="tb-mat-error">{{ 'notification.message-required' | translate }}</mat-error>
}
</ng-template>
</mat-expansion-panel>
</section>
}
@if (templateConfigurationForm.get('SLACK.enabled').value) {
<section class="tb-form-panel tb-slide-toggle stroked" [formGroupName]="NotificationDeliveryMethod.SLACK">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.SLACK) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.message</mat-label>
<textarea matInput cdkTextareaAutosize cols="1" cdkAutosizeMinRows="1" formControlName="body"></textarea>
@if (templateConfigurationForm.get('SLACK.body').hasError('required')) {
<mat-error>{{ 'notification.message-required' | translate }}</mat-error>
}
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</section>
}
@if (templateConfigurationForm.get('MICROSOFT_TEAMS.enabled').value) {
<section class="tb-form-panel tb-slide-toggle stroked" [formGroupName]="NotificationDeliveryMethod.MICROSOFT_TEAMS">
<mat-expansion-panel class="tb-settings" expanded="{{ expandedForm(NotificationDeliveryMethod.MICROSOFT_TEAMS) }}">
<mat-expansion-panel-header class="fill-width flex flex-row flex-wrap">
<mat-panel-title class="template-tittle">
<tb-icon>{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).icon }}</tb-icon>
{{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent class="tb-extension-panel">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject">
</mat-form-field>
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.message</mat-label>
<textarea matInput cdkTextareaAutosize cols="1" cdkAutosizeMinRows="1" formControlName="body"></textarea>
@if (templateConfigurationForm.get('MICROSOFT_TEAMS.body').hasError('required')) {
<mat-error>{{ 'notification.message-required' | translate }}</mat-error>
}
</mat-form-field>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row space-between">
<div translate>notification.theme-color</div>
<tb-color-input asBoxInput formControlName="themeColor"></tb-color-input>
</div>
<tb-notification-action-button-configuration
actionTitle="{{ 'notification.action-button' | translate }}"
formControlName="button">
</tb-notification-action-button-configuration>
</div>
<tb-notification-action-button-configuration
actionTitle="{{ 'notification.action-button' | translate }}"
formControlName="button">
</tb-notification-action-button-configuration>
</div>
</ng-template>
</mat-expansion-panel>
</section>
</ng-template>
</mat-expansion-panel>
</section>
}
</section>

55
ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html

@ -25,8 +25,11 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate"></mat-progress-bar>
} @else {
<div style="height: 4px;"></div>
}
<div mat-dialog-content>
<mat-horizontal-stepper linear #notificationTemplateStepper
labelPosition="end"
@ -38,33 +41,37 @@
<mat-step [stepControl]="templateNotificationForm">
<ng-template matStepLabel>{{ 'notification.setup' | translate }}</ng-template>
<form [formGroup]="templateNotificationForm" style="padding-bottom: 16px;">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>notification.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="templateNotificationForm.get('name').hasError('required')">
{{ 'notification.name-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1" *ngIf="!hideSelectType">
<mat-label translate>notification.type</mat-label>
<mat-select formControlName="notificationType">
<mat-option *ngFor="let notificationType of notificationTypes" [value]="notificationType">
{{ notificationTemplateTypeTranslateMap.get(notificationType).name | translate }}
</mat-option>
</mat-select>
@if (templateNotificationForm.get('name').hasError('required')) {
<mat-error>{{ 'notification.name-required' | translate }}</mat-error>
}
</mat-form-field>
@if (!hideSelectType) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>notification.type</mat-label>
<mat-select formControlName="notificationType">
@for (notificationType of notificationTypes; track notificationType) {
<mat-option [value]="notificationType">
{{ notificationTemplateTypeTranslateMap.get(notificationType).name | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
<section formGroupName="configuration">
<label [class.tb-error]="templateNotificationForm.get('configuration.deliveryMethodsTemplates').hasError('atLeastOne')"
class="tb-title tb-required">{{ "notification.delivery-methods" | translate }}</label>
<div class="tb-hint" translate>notification.at-least-one-should-be-selected</div>
<section formGroupName="deliveryMethodsTemplates" class="delivery-methods-container">
<section *ngFor="let deliveryMethods of notificationDeliveryMethods"
class="tb-form-panel stroked"
[formGroupName]="deliveryMethods">
<mat-slide-toggle formControlName="enabled">
{{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }}
</mat-slide-toggle>
</section>
@for (deliveryMethods of notificationDeliveryMethods; track deliveryMethods) {
<section class="tb-form-panel stroked" [formGroupName]="deliveryMethods">
<mat-slide-toggle formControlName="enabled">
{{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }}
</mat-slide-toggle>
</section>
}
</section>
</section>
</form>
@ -86,8 +93,10 @@
</div>
<mat-divider></mat-divider>
<div mat-dialog-actions class="tb-dialog-actions">
<button mat-stroked-button *ngIf="selectedIndex > 0"
(click)="backStep()">{{ 'action.back' | translate }}</button>
@if (selectedIndex > 0) {
<button mat-stroked-button
(click)="backStep()">{{ 'action.back' | translate }}</button>
}
<span class="flex-1"></span>
<button mat-raised-button
color="primary"

1
ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss

@ -92,6 +92,7 @@
width: 100%;;
overflow-y: auto;
scrollbar-gutter: stable;
padding-top: 4px;
@media #{$mat-gt-sm} {
min-width: 500px;
}

195
ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html

@ -65,34 +65,45 @@
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" class="flex flex-col min-w-full">
<div class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-full gt-xs:max-w-45%">
<mat-form-field class="mat-block flex-full gt-xs:max-w-45%" appearance="outline">
<mat-label translate>ota-update.title</mat-label>
<input matInput formControlName="title" type="text" required [readonly]="!isAdd">
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'ota-update.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')">
{{ 'ota-update.title-max-length' | translate }}
</mat-error>
@if (entityForm.get('title').hasError('required')) {
<mat-error>
{{ 'ota-update.title-required' | translate }}
</mat-error>
}
@if (entityForm.get('title').hasError('maxlength')) {
<mat-error>
{{ 'ota-update.title-max-length' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ota-update.version</mat-label>
<input matInput formControlName="version" type="text" required [readonly]="!isAdd">
<mat-error *ngIf="entityForm.get('version').hasError('required')">
{{ 'ota-update.version-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('version').hasError('maxlength')">
{{ 'ota-update.version-max-length' | translate }}
</mat-error>
@if (entityForm.get('version').hasError('required')) {
<mat-error>
{{ 'ota-update.version-required' | translate }}
</mat-error>
}
@if (entityForm.get('version').hasError('maxlength')) {
<mat-error>
{{ 'ota-update.version-max-length' | translate }}
</mat-error>
}
</mat-form-field>
</div>
<mat-form-field class="mat-block flex-1" style="margin-bottom: 8px">
<mat-form-field class="mat-block flex-1" style="margin-bottom: 8px" appearance="outline">
<mat-label translate>ota-update.version-tag</mat-label>
<input matInput formControlName="tag" type="text" [readonly]="!isAdd">
<mat-hint *ngIf="isAdd" translate>ota-update.version-tag-hint</mat-hint>
@if (isAdd) {
<mat-hint translate>ota-update.version-tag-hint</mat-hint>
}
</mat-form-field>
<tb-device-profile-autocomplete
formControlName="deviceProfileId"
appearance="outline"
required
[class.pb-4]="isAdd"
[class.pb-0]="!isAdd"
@ -101,81 +112,99 @@
[addNewProfile]="false"
[showDetailsPageLink]="true">
</tb-device-profile-autocomplete>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>ota-update.package-type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let packageType of packageTypes" [value]="packageType">
{{ otaUpdateTypeTranslationMap.get(packageType) | translate }}
</mat-option>
@for ( packageType of packageTypes; track packageType) {
<mat-option [value]="packageType">
{{ otaUpdateTypeTranslationMap.get(packageType) | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
<section *ngIf="isAdd">
<div class="mat-caption" style="margin: -8px 0 8px;" translate>ota-update.warning-after-save-no-edit</div>
<mat-radio-group formControlName="isURL" class="gap-4">
<mat-radio-button [value]="false">{{ "ota-update.upload-binary-file" | translate }}</mat-radio-button>
<mat-radio-button [value]="true">{{ "ota-update.use-external-url" | translate }}</mat-radio-button>
</mat-radio-group>
</section>
<section *ngIf="!entityForm.get('isURL').value">
<section *ngIf="isAdd">
<tb-file-input
formControlName="file"
workFromFileObj="true"
label="{{ 'ota-update.package-file' | translate }}"
[required]="!entityForm.get('isURL').value"
dropLabel="{{'ota-update.drop-package-file-or' | translate}}">
</tb-file-input>
<mat-checkbox formControlName="generateChecksum" style="margin-top: 16px">
{{ 'ota-update.auto-generate-checksum' | translate }}
</mat-checkbox>
@if (isAdd) {
<section>
<div class="mat-caption" style="margin: -8px 0 8px;" translate>ota-update.warning-after-save-no-edit</div>
<mat-radio-group formControlName="isURL" class="gap-4">
<mat-radio-button [value]="false">{{ "ota-update.upload-binary-file" | translate }}</mat-radio-button>
<mat-radio-button [value]="true">{{ "ota-update.use-external-url" | translate }}</mat-radio-button>
</mat-radio-group>
</section>
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-xs:gap-2"
*ngIf="!(isAdd && entityForm.get('generateChecksum').value)">
<mat-form-field class="mat-block flex-full gt-xs:max-w-33%">
<mat-label translate>ota-update.checksum-algorithm</mat-label>
<mat-select formControlName="checksumAlgorithm">
<mat-option *ngFor="let checksumAlgorithm of checksumAlgorithms" [value]="checksumAlgorithm">
{{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>ota-update.checksum</mat-label>
<input matInput formControlName="checksum" type="text">
<mat-hint *ngIf="isAdd" translate>ota-update.checksum-hint</mat-hint>
}
@if (!entityForm.get('isURL').value) {
<section>
@if (isAdd) {
<section>
<tb-file-input
formControlName="file"
workFromFileObj="true"
label="{{ 'ota-update.package-file' | translate }}"
[required]="!entityForm.get('isURL').value"
dropLabel="{{'ota-update.drop-package-file-or' | translate}}">
</tb-file-input>
<mat-checkbox formControlName="generateChecksum" style="margin-top: 16px">
{{ 'ota-update.auto-generate-checksum' | translate }}
</mat-checkbox>
</section>
}
@if (!(isAdd && entityForm.get('generateChecksum').value)) {
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-full gt-xs:max-w-33%" appearance="outline">
<mat-label translate>ota-update.checksum-algorithm</mat-label>
<mat-select formControlName="checksumAlgorithm">
@for ( checksumAlgorithm of checksumAlgorithms; track checksumAlgorithm) {
<mat-option [value]="checksumAlgorithm">
{{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ota-update.checksum</mat-label>
<input matInput formControlName="checksum" type="text">
@if (isAdd) {
<mat-hint translate>ota-update.checksum-hint</mat-hint>
}
</mat-form-field>
</div>
}
@if (!isAdd) {
<section>
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="mat-block flex-full gt-xs:max-w-33%" appearance="outline">
<mat-label translate>ota-update.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ota-update.file-size-bytes</mat-label>
<input matInput formControlName="dataSize" type="text">
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ota-update.content-type</mat-label>
<input matInput formControlName="contentType" type="text">
</mat-form-field>
</div>
</section>
}
</section>
}
@if (entityForm.get('isURL').value) {
<section style="margin-top: 8px">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>ota-update.direct-url</mat-label>
<input matInput formControlName="url"
type="text"
[required]="entityForm.get('isURL').value">
@if (entityForm.get('url').hasError('required') || entityForm.get('url').hasError('pattern')) {
<mat-error translate>
ota-update.direct-url-required
</mat-error>
}
</mat-form-field>
</div>
<section *ngIf="!isAdd">
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="mat-block flex-full gt-xs:max-w-33%">
<mat-label translate>ota-update.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>ota-update.file-size-bytes</mat-label>
<input matInput formControlName="dataSize" type="text">
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>ota-update.content-type</mat-label>
<input matInput formControlName="contentType" type="text">
</mat-form-field>
</div>
</section>
</section>
<section *ngIf="entityForm.get('isURL').value" style="margin-top: 8px">
<mat-form-field class="mat-block">
<mat-label translate>ota-update.direct-url</mat-label>
<input matInput formControlName="url"
type="text"
[required]="entityForm.get('isURL').value">
<mat-error *ngIf="entityForm.get('url').hasError('required') || entityForm.get('url').hasError('pattern')"
translate>
ota-update.direct-url-required
</mat-error>
</mat-form-field>
</section>
}
<div formGroupName="additionalInfo">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>ota-update.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

60
ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html

@ -23,16 +23,19 @@
</div>
<form [formGroup]="ruleNodeFormGroup" class="mat-padding">
<section class="title-row align-center flex">
<mat-form-field class="mat-block flex-1">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>rulenode.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="ruleNodeFormGroup.get('name').hasError('required')
|| ruleNodeFormGroup.get('name').hasError('pattern')">
{{ 'rulenode.name-required' | translate }}
</mat-error>
<mat-error *ngIf="ruleNodeFormGroup.get('name').hasError('maxlength')">
{{ 'rulenode.name-max-length' | translate }}
</mat-error>
@if (ruleNodeFormGroup.get('name').hasError('required') || ruleNodeFormGroup.get('name').hasError('pattern')) {
<mat-error>
{{ 'rulenode.name-required' | translate }}
</mat-error>
}
@if (ruleNodeFormGroup.get('name').hasError('maxlength')) {
<mat-error>
{{ 'rulenode.name-max-length' | translate }}
</mat-error>
}
</mat-form-field>
<section class="mb-5 flex max-w-xs flex-row">
<tb-entity-debug-settings-button
@ -41,17 +44,18 @@
formControlName="debugSettings"
[entityType]="entityType.RULE_NODE"
/>
<button mat-stroked-button
class="tb-rounded-btn flex-1"
color="primary"
*ngIf="isSingleton()"
[disabled]="ruleNodeFormGroup.get('singletonMode').disabled || disabled"
[class.active]="ruleNodeFormGroup.get('singletonMode').value"
(click)="onSingleModeChange($event)">
<mat-icon [class.invisible]="!ruleNodeFormGroup.get('singletonMode').value">checkmark</mat-icon>
<span translate>rulenode.singleton</span>
<input class="hidden" formControlName="singletonMode">
</button>
@if (isSingleton()) {
<button mat-stroked-button
class="tb-rounded-btn flex-1"
color="primary"
[disabled]="ruleNodeFormGroup.get('singletonMode').disabled || disabled"
[class.active]="ruleNodeFormGroup.get('singletonMode').value"
(click)="onSingleModeChange($event)">
<mat-icon [class.invisible]="!ruleNodeFormGroup.get('singletonMode').value">checkmark</mat-icon>
<span translate>rulenode.singleton</span>
<input class="hidden" formControlName="singletonMode">
</button>
}
</section>
</section>
<tb-rule-node-config #ruleNodeConfigComponent
@ -65,15 +69,17 @@
</tb-rule-node-config>
<div class="description-block">
<tb-queue-autocomplete
*ngIf="isAddQueue()"
[queueType]="serviceType"
subscriptSizing="dynamic"
[autocompleteHint]="isSingleton() ? 'rulenode.queue-singleton-hint' : 'rulenode.queue-hint'"
formControlName="queueName">
</tb-queue-autocomplete>
@if (isAddQueue()) {
<tb-queue-autocomplete
[queueType]="serviceType"
appearance="outline"
subscriptSizing="dynamic"
[autocompleteHint]="isSingleton() ? 'rulenode.queue-singleton-hint' : 'rulenode.queue-hint'"
formControlName="queueName">
</tb-queue-autocomplete>
}
<div formGroupName="additionalInfo">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>rulenode.rule-node-description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

18
ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html

@ -77,22 +77,22 @@
</div>
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<fieldset [disabled]="(isLoading$ | async) || !isEdit" style="min-width: 0;">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>rulechain.name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'rulechain.name-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'rulechain.name-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>{{ 'rulechain.name-required' | translate }}</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>{{ 'rulechain.name-max-length' | translate }}</mat-error>
}
</mat-form-field>
<mat-checkbox class="flex-1" formControlName="debugMode" style="padding-bottom: 16px;">
{{ 'rulechain.debug-mode' | translate }}
</mat-checkbox>
<div formGroupName="additionalInfo">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>rulechain.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1"></textarea>
</mat-form-field>

21
ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html

@ -44,15 +44,19 @@
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>widget.title</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="entityForm.get('name').hasError('required')">
{{ 'widget.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('name').hasError('maxlength')">
{{ 'widget.title-max-length' | translate }}
</mat-error>
@if (entityForm.get('name').hasError('required')) {
<mat-error>
{{ 'widget.title-required' | translate }}
</mat-error>
}
@if (entityForm.get('name').hasError('maxlength')) {
<mat-error>
{{ 'widget.title-max-length' | translate }}
</mat-error>
}
</mat-form-field>
<tb-gallery-image-input class="flex-1"
label="{{'widget.image-preview' | translate}}"
@ -60,6 +64,7 @@
</tb-gallery-image-input>
<tb-string-items-list
label="{{ 'widget.tags' | translate }}"
appearance="outline"
formControlName="tags">
</tb-string-items-list>
<div class="flex flex-col gap-4">
@ -70,7 +75,7 @@
{{ 'widget.deprecated' | translate }}
</mat-slide-toggle>
</div>
<mat-form-field class="mat-block mt-5">
<mat-form-field class="mat-block mt-5" appearance="outline">
<mat-label translate>widget.description</mat-label>
<textarea matInput formControlName="description" cdkTextareaAutosize cdkAutosizeMinRows="1" rows="1" maxlength="1024" #descriptionInput></textarea>
<mat-hint align="end">{{descriptionInput.value?.length || 0}}/1024</mat-hint>

38
ui-ngx/src/app/shared/components/contact.component.html

@ -17,54 +17,56 @@
-->
<section [formGroup]="parentForm">
<tb-country-autocomplete
appearance="outline"
formControlName="country"
(selectCountryCode)="changeCountry($event)">
</tb-country-autocomplete>
<div class="tb-form-row column-xs tb-standard-fields no-border no-padding">
<mat-form-field class="flex" subscriptSizing="dynamic">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>contact.city</mat-label>
<input matInput formControlName="city">
<mat-error *ngIf="parentForm.get('city').hasError('maxlength')">
{{ 'contact.city-max-length' | translate }}
</mat-error>
@if (parentForm.get('city').hasError('maxlength')) {
<mat-error>{{ 'contact.city-max-length' | translate }}</mat-error>
}
<mat-hint></mat-hint>
</mat-form-field>
<mat-form-field class="flex" subscriptSizing="dynamic">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>contact.state</mat-label>
<input matInput formControlName="state">
<mat-error *ngIf="parentForm.get('state').hasError('maxlength')">
{{ 'contact.state-max-length' | translate }}
</mat-error>
@if (parentForm.get('state').hasError('maxlength')) {
<mat-error>{{ 'contact.state-max-length' | translate }}</mat-error>
}
<mat-hint></mat-hint>
</mat-form-field>
<mat-form-field class="flex" subscriptSizing="dynamic">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>contact.postal-code</mat-label>
<input matInput formControlName="zip">
<mat-error *ngIf="parentForm.get('zip').hasError('pattern')">
{{ 'contact.postal-code-invalid' | translate }}
</mat-error>
@if (parentForm.get('zip').hasError('pattern')) {
<mat-error>{{ 'contact.postal-code-invalid' | translate }}</mat-error>
}
<mat-hint></mat-hint>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>contact.address</mat-label>
<input matInput formControlName="address">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>contact.address2</mat-label>
<input matInput formControlName="address2">
</mat-form-field>
<tb-phone-input [required]="false"
[defaultCountry]="phoneInputDefaultCountry"
label="{{ 'contact.phone' | translate }}"
appearance="outline"
[enableFlagsSelect]="true"
formControlName="phone">
</tb-phone-input>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>contact.email</mat-label>
<input matInput type="email" formControlName="email">
<mat-error *ngIf="parentForm.get('email').hasError('email')">
{{ 'user.invalid-email-format' | translate }}
</mat-error>
@if (parentForm.get('email').hasError('email')) {
<mat-error>{{ 'user.invalid-email-format' | translate }}</mat-error>
}
</mat-form-field>
</section>

2
ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field [formGroup]="selectDashboardStateFormGroup" class="mat-block">
<mat-form-field [formGroup]="selectDashboardStateFormGroup" class="mat-block" [appearance]="appearance">
<mat-label>{{ label }}</mat-label>
<input matInput type="text" placeholder="{{ placeholder }}"
#dashboardStateInput

5
ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.ts

@ -24,7 +24,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { FloatLabelType } from '@angular/material/form-field';
import { MatFormFieldAppearance } from '@angular/material/form-field';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@Component({
@ -51,6 +51,9 @@ export class DashboardStateAutocompleteComponent implements ControlValueAccessor
@Input()
label: string = this.translate.instant('widget-action.target-dashboard-state');
@Input()
appearance: MatFormFieldAppearance = 'fill';
@Input()
placeholder: string;

2
ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field [formGroup]="keysListFormGroup" class="mat-block tb-chip-list">
<mat-form-field [formGroup]="keysListFormGroup" class="mat-block tb-chip-list" [appearance]="appearance">
<mat-chip-grid #chipList>
<mat-chip-row
*ngFor="let key of modelValue"

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

Loading…
Cancel
Save