Browse Source

Merge remote-tracking branch 'upstream/develop/3.5.2' into feature/create-latest-telemetry-key-value

pull/9003/head
rusikv 3 years ago
parent
commit
ff018246cf
  1. 16
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 5
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  3. 2
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  4. 107
      application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java
  5. 86
      application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java
  7. 33
      application/src/main/resources/thingsboard.yml
  8. 49
      application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java
  9. 340
      application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
  10. 160
      application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
  11. 29
      common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java
  12. 42
      common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java
  13. 189
      common/queue/src/test/java/org/thingsboard/server/queue/discovery/ZkDiscoveryServiceTest.java
  14. 7
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java
  15. 8
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java
  16. 73
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImplTest.java
  17. 34
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityConfiguration.java
  18. 26
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityInfo.java
  19. 268
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java
  20. 9
      dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java
  21. 16
      dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java
  22. 123
      dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java
  23. 1
      msa/vc-executor/src/main/resources/tb-vc-executor.yml
  24. 2
      pom.xml
  25. 1
      transport/coap/src/main/resources/tb-coap-transport.yml
  26. 1
      transport/http/src/main/resources/tb-http-transport.yml
  27. 1
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  28. 1
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  29. 1
      transport/snmp/src/main/resources/tb-snmp-transport.yml
  30. 6
      ui-ngx/src/app/app.component.ts
  31. 19
      ui-ngx/src/app/core/auth/auth.actions.ts
  32. 14
      ui-ngx/src/app/core/auth/auth.effects.ts
  33. 15
      ui-ngx/src/app/core/auth/auth.reducer.ts
  34. 6
      ui-ngx/src/app/core/auth/auth.selectors.ts
  35. 2
      ui-ngx/src/app/core/auth/auth.service.ts
  36. 10
      ui-ngx/src/app/core/http/device.service.ts
  37. 6
      ui-ngx/src/app/core/services/dashboard-utils.service.ts
  38. 24
      ui-ngx/src/app/core/utils.ts
  39. 22
      ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.html
  40. 20
      ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.scss
  41. 2
      ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss
  42. 18
      ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html
  43. 13
      ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.scss
  44. 25
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  45. 3
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  46. 37
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss
  47. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss
  48. 44
      ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html
  49. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.scss
  50. 2
      ui-ngx/src/app/modules/home/components/details-panel.component.html
  51. 13
      ui-ngx/src/app/modules/home/components/details-panel.component.scss
  52. 2
      ui-ngx/src/app/modules/home/components/event/event-table-config.ts
  53. 1
      ui-ngx/src/app/modules/home/components/router-tabs.component.html
  54. 62
      ui-ngx/src/app/modules/home/components/router-tabs.component.ts
  55. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.html
  56. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-config.scss
  57. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/entities-table-basic-config.component.html
  58. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.html
  59. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/timeseries-table-basic-config.component.html
  60. 46
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html
  61. 10
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts
  62. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/flot-basic-config.component.html
  63. 9
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html
  64. 37
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss
  65. 7
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html
  66. 44
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.scss
  67. 2
      ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html
  68. 10
      ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.scss
  69. 11
      ui-ngx/src/app/modules/home/components/widget/config/timewindow-config-panel.component.html
  70. 10
      ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts
  71. 20
      ui-ngx/src/app/modules/home/components/widget/config/widget-settings.models.ts
  72. 4
      ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.scss
  73. 4
      ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.scss
  74. 44
      ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html
  75. 48
      ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.scss
  76. 8
      ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts
  77. 2
      ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.scss
  78. 89
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html
  79. 200
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts
  80. 87
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html
  81. 73
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.scss
  82. 120
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts
  83. 30
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.html
  84. 41
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.scss
  85. 120
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.ts
  86. 27
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts
  87. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.html
  88. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html
  89. 20
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  90. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss
  91. 2
      ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.scss
  92. 3
      ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts
  93. 23
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  94. 35
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  95. 17
      ui-ngx/src/app/modules/home/components/widget/widget-container.component.html
  96. 47
      ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss
  97. 1
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  98. 2
      ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html
  99. 9
      ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts
  100. 2
      ui-ngx/src/app/modules/home/menu/side-menu.component.scss

16
application/src/main/data/json/system/widget_bundles/cards.json

File diff suppressed because one or more lines are too long

5
application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java

@ -81,6 +81,7 @@ public class ThingsboardSecurityConfiguration {
public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
public static final String WS_TOKEN_BASED_AUTH_ENTRY_POINT = "/api/ws/**";
public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code";
public static final String DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT = "/api/device-connectivity/mqtts/certificate/download";
@Autowired private ThingsboardErrorResponseHandler restAccessDeniedHandler;
@ -150,7 +151,8 @@ public class ThingsboardSecurityConfiguration {
protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception {
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS));
pathsToSkip.addAll(Arrays.asList(WS_TOKEN_BASED_AUTH_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT,
PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT));
PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT,
DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT));
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
JwtTokenAuthenticationProcessingFilter filter
= new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher);
@ -228,6 +230,7 @@ public class ThingsboardSecurityConfiguration {
.antMatchers(PUBLIC_LOGIN_ENTRY_POINT).permitAll() // Public login end-point
.antMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point
.antMatchers(MAIL_OAUTH2_PROCESSING_ENTRY_POINT).permitAll() // Mail oauth2 code processing url
.antMatchers(DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT).permitAll() // Mail oauth2 code processing url
.antMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points
.and()
.authorizeRequests()

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

@ -24,6 +24,7 @@ public class ControllerConstants {
protected static final String CUSTOMER_ID = "customerId";
protected static final String TENANT_ID = "tenantId";
protected static final String DEVICE_ID = "deviceId";
protected static final String PROTOCOL = "protocol";
protected static final String EDGE_ID = "edgeId";
protected static final String RPC_ID = "rpcId";
protected static final String ENTITY_ID = "entityId";
@ -34,6 +35,7 @@ public class ControllerConstants {
protected static final String DASHBOARD_ID_PARAM_DESCRIPTION = "A string value representing the dashboard id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String RPC_ID_PARAM_DESCRIPTION = "A string value representing the rpc id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String DEVICE_ID_PARAM_DESCRIPTION = "A string value representing the device id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String PROTOCOL_PARAM_DESCRIPTION = "A string value representing the device connectivity protocol. Possible values: 'mqtt', 'mqtts', 'http', 'https', 'coap', 'coaps'";
protected static final String ENTITY_VIEW_ID_PARAM_DESCRIPTION = "A string value representing the entity view id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String DEVICE_PROFILE_ID_PARAM_DESCRIPTION = "A string value representing the device profile id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";

107
application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java

@ -0,0 +1,107 @@
/**
* Copyright © 2016-2023 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.controller;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.dao.device.DeviceConnectivityService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.net.URISyntaxException;
import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID;
import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PROTOCOL;
import static org.thingsboard.server.controller.ControllerConstants.PROTOCOL_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.PEM_CERT_FILE_NAME;
@RestController
@TbCoreComponent
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class DeviceConnectivityController extends BaseController {
private final DeviceConnectivityService deviceConnectivityService;
private final SystemSecurityService systemSecurityService;
@ApiOperation(value = "Get commands to publish device telemetry (getDevicePublishTelemetryCommands)",
notes = "Fetch the list of commands to publish device telemetry based on device profile " +
"If the user has the authority of 'Tenant Administrator', the server checks that the device is owned by the same tenant. " +
"If the user has the authority of 'Customer User', the server checks that the device is assigned to the same customer. " +
TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
@ApiResponses(value = {
@ApiResponse(code = 200, message = "OK",
examples = @io.swagger.annotations.Example(
value = {
@io.swagger.annotations.ExampleProperty(
mediaType = "application/json",
value = "{\"http\":\"curl -v -X POST http://localhost:8080/api/v1/0ySs4FTOn5WU15XLmal8/telemetry --header Content-Type:application/json --data {temperature:25}\"," +
"\"mqtt\":\"mosquitto_pub -d -q 1 -h localhost -t v1/devices/me/telemetry -i myClient1 -u myUsername1 -P myPassword -m {temperature:25}\"," +
"\"coap\":\"coap-client -m POST coap://localhost:5683/api/v1/0ySs4FTOn5WU15XLmal8/telemetry -t json -e {temperature:25}\"}")}))})
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/device-connectivity/{deviceId}", method = RequestMethod.GET)
@ResponseBody
public JsonNode getDevicePublishTelemetryCommands(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION)
@PathVariable(DEVICE_ID) String strDeviceId, HttpServletRequest request) throws ThingsboardException, URISyntaxException {
checkParameter(DEVICE_ID, strDeviceId);
DeviceId deviceId = new DeviceId(toUUID(strDeviceId));
Device device = checkDeviceId(deviceId, Operation.READ_CREDENTIALS);
String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request);
return deviceConnectivityService.findDevicePublishTelemetryCommands(baseUrl, device);
}
@ApiOperation(value = "Download server certificate using file path defined in device.connectivity properties (downloadServerCertificate)", notes = "Download server certificate.")
@RequestMapping(value = "/device-connectivity/{protocol}/certificate/download", method = RequestMethod.GET)
@ResponseBody
public ResponseEntity<org.springframework.core.io.Resource> downloadServerCertificate(@ApiParam(value = PROTOCOL_PARAM_DESCRIPTION)
@PathVariable(PROTOCOL) String protocol) throws ThingsboardException, IOException {
checkParameter(PROTOCOL, protocol);
var pemCert =
checkNotNull(deviceConnectivityService.getPemCertFile(protocol), protocol + " pem cert file is not found!");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + PEM_CERT_FILE_NAME)
.header("x-filename", PEM_CERT_FILE_NAME)
.contentLength(pemCert.contentLength())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(pemCert);
}
}

86
application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java

@ -221,12 +221,12 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
.build();
}
private class SessionMetaData implements SendHandler {
class SessionMetaData implements SendHandler {
private final WebSocketSession session;
private final RemoteEndpoint.Async asyncRemote;
private final WebSocketSessionRef sessionRef;
private final AtomicBoolean isSending = new AtomicBoolean(false);
final AtomicBoolean isSending = new AtomicBoolean(false);
private final Queue<TbWebSocketMsg<?>> msgQueue;
private volatile long lastActivityTime;
@ -241,7 +241,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
this.lastActivityTime = System.currentTimeMillis();
}
synchronized void sendPing(long currentTime) {
void sendPing(long currentTime) {
try {
long timeSinceLastActivity = currentTime - lastActivityTime;
if (timeSinceLastActivity >= pingTimeout) {
@ -256,37 +256,38 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
}
private void closeSession(CloseStatus reason) {
void closeSession(CloseStatus reason) {
try {
close(this.sessionRef, reason);
} catch (IOException ioe) {
log.trace("[{}] Session transport error", session.getId(), ioe);
} finally {
msgQueue.clear();
}
}
synchronized void processPongMessage(long currentTime) {
void processPongMessage(long currentTime) {
lastActivityTime = currentTime;
}
synchronized void sendMsg(String msg) {
void sendMsg(String msg) {
sendMsg(new TbWebSocketTextMsg(msg));
}
synchronized void sendMsg(TbWebSocketMsg<?> msg) {
if (isSending.compareAndSet(false, true)) {
sendMsgInternal(msg);
} else {
try {
msgQueue.add(msg);
} catch (RuntimeException e) {
if (log.isTraceEnabled()) {
log.trace("[{}][{}] Session closed due to queue error", sessionRef.getSecurityCtx().getTenantId(), session.getId(), e);
} else {
log.info("[{}][{}] Session closed due to queue error", sessionRef.getSecurityCtx().getTenantId(), session.getId());
}
closeSession(CloseStatus.POLICY_VIOLATION.withReason("Max pending updates limit reached!"));
void sendMsg(TbWebSocketMsg<?> msg) {
try {
msgQueue.add(msg);
} catch (RuntimeException e) {
if (log.isTraceEnabled()) {
log.trace("[{}][{}] Session closed due to queue error", sessionRef.getSecurityCtx().getTenantId(), session.getId(), e);
} else {
log.info("[{}][{}] Session closed due to queue error", sessionRef.getSecurityCtx().getTenantId(), session.getId());
}
closeSession(CloseStatus.POLICY_VIOLATION.withReason("Max pending updates limit reached!"));
return;
}
processNextMsg();
}
private void sendMsgInternal(TbWebSocketMsg<?> msg) {
@ -294,9 +295,11 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
if (TbWebSocketMsgType.TEXT.equals(msg.getType())) {
TbWebSocketTextMsg textMsg = (TbWebSocketTextMsg) msg;
this.asyncRemote.sendText(textMsg.getMsg(), this);
// isSending status will be reset in the onResult method by call back
} else {
TbWebSocketPingMsg pingMsg = (TbWebSocketPingMsg) msg;
this.asyncRemote.sendPing(pingMsg.getMsg());
this.asyncRemote.sendPing(pingMsg.getMsg()); // blocking call
isSending.set(false);
processNextMsg();
}
} catch (Exception e) {
@ -310,12 +313,17 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
if (!result.isOK()) {
log.trace("[{}] Failed to send msg", session.getId(), result.getException());
closeSession(CloseStatus.SESSION_NOT_RELIABLE);
} else {
processNextMsg();
return;
}
isSending.set(false);
processNextMsg();
}
private void processNextMsg() {
if (msgQueue.isEmpty() || !isSending.compareAndSet(false, true)) {
return;
}
TbWebSocketMsg<?> msg = msgQueue.poll();
if (msg != null) {
sendMsgInternal(msg);
@ -392,19 +400,21 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
if (tenantProfileConfiguration == null) {
return true;
}
boolean limitAllowed;
String sessionId = session.getId();
if (tenantProfileConfiguration.getMaxWsSessionsPerTenant() > 0) {
Set<String> tenantSessions = tenantSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (tenantSessions) {
if (tenantSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerTenant()) {
limitAllowed = tenantSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerTenant();
if (limitAllowed) {
tenantSessions.add(sessionId);
} else {
}
}
if (!limitAllowed) {
log.info("[{}][{}][{}] Failed to start session. Max tenant sessions limit reached"
, sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId);
session.close(CloseStatus.POLICY_VIOLATION.withReason("Max tenant sessions limit reached!"));
return false;
}
}
}
@ -412,42 +422,48 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
if (tenantProfileConfiguration.getMaxWsSessionsPerCustomer() > 0) {
Set<String> customerSessions = customerSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getCustomerId(), id -> ConcurrentHashMap.newKeySet());
synchronized (customerSessions) {
if (customerSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerCustomer()) {
limitAllowed = customerSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerCustomer();
if (limitAllowed) {
customerSessions.add(sessionId);
} else {
}
}
if (!limitAllowed) {
log.info("[{}][{}][{}] Failed to start session. Max customer sessions limit reached"
, sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId);
session.close(CloseStatus.POLICY_VIOLATION.withReason("Max customer sessions limit reached"));
return false;
}
}
}
if (tenantProfileConfiguration.getMaxWsSessionsPerRegularUser() > 0
&& UserPrincipal.Type.USER_NAME.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
Set<String> regularUserSessions = regularUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
synchronized (regularUserSessions) {
if (regularUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerRegularUser()) {
limitAllowed = regularUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerRegularUser();
if (limitAllowed) {
regularUserSessions.add(sessionId);
} else {
}
}
if (!limitAllowed) {
log.info("[{}][{}][{}] Failed to start session. Max regular user sessions limit reached"
, sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId);
session.close(CloseStatus.POLICY_VIOLATION.withReason("Max regular user sessions limit reached"));
return false;
}
}
}
if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0
&& UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
Set<String> publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser()) {
limitAllowed = publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser();
if (limitAllowed) {
publicUserSessions.add(sessionId);
} else {
}
}
if (!limitAllowed) {
log.info("[{}][{}][{}] Failed to start session. Max public user sessions limit reached"
, sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId);
session.close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached"));
return false;
}
}
}
}

2
application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java

@ -82,7 +82,7 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse
UserId userId = tbUser.getId();
try {
tbAlarmService.unassignUserAlarms(tenantId, tbUser, System.currentTimeMillis());
tbAlarmService.unassignUserAlarms(tbUser.getTenantId(), tbUser, System.currentTimeMillis());
userService.deleteUser(tenantId, userId);
notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, customerId, userId, tbUser,
user, ActionType.DELETED, true, null, customerId.toString());

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

@ -96,6 +96,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
cluster:
stats:
@ -249,7 +250,8 @@ cassandra:
concurrent_limit: "${CASSANDRA_QUERY_CONCURRENT_LIMIT:1000}"
permit_max_wait_time: "${PERMIT_MAX_WAIT_TIME:120000}"
dispatcher_threads: "${CASSANDRA_QUERY_DISPATCHER_THREADS:2}"
callback_threads: "${CASSANDRA_QUERY_CALLBACK_THREADS:4}"
callback_threads: "${CASSANDRA_QUERY_CALLBACK_THREADS:4}" # Buffered rate executor (read, write), for managing I/O rate. See "nosql-*-callback" threads in JMX
result_processing_threads: "${CASSANDRA_QUERY_RESULT_PROCESSING_THREADS:50}" # Result set transformer and processing. See "cassandra-callback" threads in JMX
poll_ms: "${CASSANDRA_QUERY_POLL_MS:50}"
rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}"
# set all data types values except target to null for the same ts on save
@ -985,6 +987,35 @@ transport:
enabled: "${TB_TRANSPORT_STATS_ENABLED:true}"
print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}"
# Device connectivity properties to publish telemetry
device:
connectivity:
http:
enabled: "${DEVICE_CONNECTIVITY_HTTP_ENABLED:true}"
host: "${DEVICE_CONNECTIVITY_HTTP_HOST:}"
port: "${DEVICE_CONNECTIVITY_HTTP_PORT:8080}"
https:
enabled: "${DEVICE_CONNECTIVITY_HTTPS_ENABLED:false}"
host: "${DEVICE_CONNECTIVITY_HTTPS_HOST:}"
port: "${DEVICE_CONNECTIVITY_HTTPS_PORT:443}"
mqtt:
enabled: "${DEVICE_CONNECTIVITY_MQTT_ENABLED:true}"
host: "${DEVICE_CONNECTIVITY_MQTT_HOST:}"
port: "${DEVICE_CONNECTIVITY_MQTT_PORT:1883}"
mqtts:
enabled: "${DEVICE_CONNECTIVITY_MQTTS_ENABLED:false}"
host: "${DEVICE_CONNECTIVITY_MQTTS_HOST:}"
port: "${DEVICE_CONNECTIVITY_MQTTS_PORT:8883}"
pem_cert_file: "${DEVICE_CONNECTIVITY_MQTT_SSL_PEM_CERT:mqttserver.pem}"
coap:
enabled: "${DEVICE_CONNECTIVITY_COAP_ENABLED:true}"
host: "${DEVICE_CONNECTIVITY_COAP_HOST:}"
port: "${DEVICE_CONNECTIVITY_COAP_PORT:5683}"
coaps:
enabled: "${DEVICE_CONNECTIVITY_COAPS_ENABLED:false}"
host: "${DEVICE_CONNECTIVITY_COAPS_HOST:}"
port: "${DEVICE_CONNECTIVITY_COAPS_PORT:5684}"
# Edges parameters
edges:
enabled: "${EDGES_ENABLED:true}"

49
application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java

@ -531,6 +531,55 @@ public class AlarmControllerTest extends AbstractControllerTest {
tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.ALARM_UNASSIGNED);
}
@Test
public void testUnassignTenantUserAlarmOnUserRemoving() throws Exception {
loginDifferentTenant();
User user = new User();
user.setAuthority(Authority.TENANT_ADMIN);
user.setTenantId(tenantId);
user.setEmail("tenantForAssign@thingsboard.org");
User savedUser = createUser(user, "password");
Device device = createDevice("Different tenant device", "default", "differentTenantTest");
Alarm alarm = Alarm.builder()
.type(TEST_ALARM_TYPE)
.tenantId(savedDifferentTenant.getId())
.originator(device.getId())
.severity(AlarmSeverity.MAJOR)
.build();
alarm = doPost("/api/alarm", alarm, Alarm.class);
Assert.assertNotNull(alarm);
alarm = doGet("/api/alarm/info/" + alarm.getId(), AlarmInfo.class);
Assert.assertNotNull(alarm);
Mockito.reset(tbClusterService, auditLogService);
long beforeAssignmentTs = System.currentTimeMillis();
doPost("/api/alarm/" + alarm.getId() + "/assign/" + savedUser.getId().getId()).andExpect(status().isOk());
AlarmInfo foundAlarm = doGet("/api/alarm/info/" + alarm.getId(), AlarmInfo.class);
Assert.assertNotNull(foundAlarm);
Assert.assertEquals(savedUser.getId(), foundAlarm.getAssigneeId());
Assert.assertTrue(foundAlarm.getAssignTs() >= beforeAssignmentTs);
beforeAssignmentTs = System.currentTimeMillis();
Mockito.reset(tbClusterService, auditLogService);
loginSysAdmin();
doDelete("/api/user/" + savedUser.getId().getId()).andExpect(status().isOk());
loginDifferentTenant();
foundAlarm = doGet("/api/alarm/info/" + alarm.getId(), AlarmInfo.class);
Assert.assertNotNull(foundAlarm);
Assert.assertNull(foundAlarm.getAssigneeId());
Assert.assertTrue(foundAlarm.getAssignTs() >= beforeAssignmentTs);
}
@Test
public void testUnassignAlarmOnUserRemoving() throws Exception {
loginDifferentTenant();

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

@ -0,0 +1,340 @@
/**
* Copyright © 2016-2023 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.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.AdditionalAnswers;
import org.mockito.Mockito;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileType;
import org.thingsboard.server.common.data.DeviceTransportType;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.device.profile.CoapDeviceProfileTransportConfiguration;
import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration;
import org.thingsboard.server.common.data.device.profile.DeviceProfileData;
import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.dao.device.DeviceDao;
import org.thingsboard.server.dao.service.DaoSqlTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.COAP;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.COAPS;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.DOCKER;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.HTTP;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.HTTPS;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.MQTT;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.MQTTS;
@TestPropertySource(properties = {
"device.connectivity.https.enabled=true",
"device.connectivity.mqtts.enabled=true",
"device.connectivity.coaps.enabled=true",
})
@ContextConfiguration(classes = {DeviceConnectivityControllerTest.Config.class})
@DaoSqlTest
public class DeviceConnectivityControllerTest extends AbstractControllerTest {
static final TypeReference<PageData<Device>> PAGE_DATA_DEVICE_TYPE_REF = new TypeReference<>() {
};
private static final String DEVICE_TELEMETRY_TOPIC = "v1/devices/customTopic";
private static final String CHECK_DOCUMENTATION = "Check documentation";
ListeningExecutorService executor;
private Tenant savedTenant;
private User tenantAdmin;
private DeviceProfileId mqttDeviceProfileId;
private DeviceProfileId coapDeviceProfileId;
static class Config {
@Bean
@Primary
public DeviceDao deviceDao(DeviceDao deviceDao) {
return Mockito.mock(DeviceDao.class, AdditionalAnswers.delegatesTo(deviceDao));
}
}
@Before
public void beforeTest() throws Exception {
executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass()));
loginSysAdmin();
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
savedTenant = doPost("/api/tenant", tenant, Tenant.class);
Assert.assertNotNull(savedTenant);
tenantAdmin = new User();
tenantAdmin.setAuthority(Authority.TENANT_ADMIN);
tenantAdmin.setTenantId(savedTenant.getId());
tenantAdmin.setEmail("tenant2@thingsboard.org");
tenantAdmin.setFirstName("Joe");
tenantAdmin.setLastName("Downs");
tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1");
DeviceProfile mqttProfile = new DeviceProfile();
mqttProfile.setName("Mqtt device profile");
mqttProfile.setType(DeviceProfileType.DEFAULT);
mqttProfile.setTransportType(DeviceTransportType.MQTT);
DeviceProfileData deviceProfileData = new DeviceProfileData();
deviceProfileData.setConfiguration(new DefaultDeviceProfileConfiguration());
MqttDeviceProfileTransportConfiguration transportConfiguration = new MqttDeviceProfileTransportConfiguration();
transportConfiguration.setDeviceTelemetryTopic(DEVICE_TELEMETRY_TOPIC);
deviceProfileData.setTransportConfiguration(transportConfiguration);
mqttProfile.setProfileData(deviceProfileData);
mqttProfile.setDefault(false);
mqttProfile.setDefaultRuleChainId(null);
mqttDeviceProfileId = doPost("/api/deviceProfile", mqttProfile, DeviceProfile.class).getId();
DeviceProfile coapProfile = new DeviceProfile();
coapProfile.setName("Coap device profile");
coapProfile.setType(DeviceProfileType.DEFAULT);
coapProfile.setTransportType(DeviceTransportType.COAP);
DeviceProfileData deviceProfileData2 = new DeviceProfileData();
deviceProfileData2.setConfiguration(new DefaultDeviceProfileConfiguration());
deviceProfileData2.setTransportConfiguration(new CoapDeviceProfileTransportConfiguration());
coapProfile.setProfileData(deviceProfileData);
coapProfile.setDefault(false);
coapProfile.setDefaultRuleChainId(null);
coapDeviceProfileId = doPost("/api/deviceProfile", coapProfile, DeviceProfile.class).getId();
}
@After
public void afterTest() throws Exception {
executor.shutdownNow();
loginSysAdmin();
doDelete("/api/tenant/" + savedTenant.getId().getId())
.andExpect(status().isOk());
}
@Test
public void testFetchPublishTelemetryCommandsForDefaultDevice() throws Exception {
Device device = new Device();
device.setName("My device");
device.setType("default");
Device savedDevice = doPost("/api/device", device, Device.class);
JsonNode commands =
doGetTyped("/api/device-connectivity/" + savedDevice.getId().getId(), new TypeReference<>() {
});
DeviceCredentials credentials =
doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class);
assertThat(commands).hasSize(3);
JsonNode httpCommands = commands.get(HTTP);
assertThat(httpCommands.get(HTTP).asText()).isEqualTo(String.format("curl -v -X POST http://localhost:8080/api/v1/%s/telemetry " +
"--header Content-Type:application/json --data \"{temperature:25}\"",
credentials.getCredentialsId()));
assertThat(httpCommands.get(HTTPS).asText()).isEqualTo(String.format("curl -v -X POST https://localhost:443/api/v1/%s/telemetry " +
"--header Content-Type:application/json --data \"{temperature:25}\"",
credentials.getCredentialsId()));
JsonNode mqttCommands = commands.get(MQTT);
assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t v1/devices/me/telemetry " +
"-u %s -m \"{temperature:25}\"",
credentials.getCredentialsId()));
assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o tb-server-chain.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download");
assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile tb-server-chain.pem -h localhost -p 8883 " +
"-t v1/devices/me/telemetry -u %s -m \"{temperature:25}\"", credentials.getCredentialsId()));
JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER);
assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" +
" -p 1883 -t v1/devices/me/telemetry -u %s -m \"{temperature:25}\"",
credentials.getCredentialsId()));
assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " +
"/bin/sh -c \"curl -f -S -o tb-server-chain.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " +
"mosquitto_pub -d -q 1 --cafile tb-server-chain.pem -h localhost -p 8883 -t v1/devices/me/telemetry -u %s -m \"{temperature:25}\"\"",
credentials.getCredentialsId()));
JsonNode linuxCoapCommands = commands.get(COAP);
assertThat(linuxCoapCommands.get(COAP).asText()).isEqualTo(String.format("coap-client -m POST coap://localhost:5683/api/v1/%s/telemetry " +
"-t json -e \"{temperature:25}\"", credentials.getCredentialsId()));
assertThat(linuxCoapCommands.get(COAPS).asText()).isEqualTo(String.format("coap-client-openssl -m POST coaps://localhost:5684/api/v1/%s/telemetry" +
" -t json -e \"{temperature:25}\"", credentials.getCredentialsId()));
}
@Test
public void testFetchPublishTelemetryCommandsForMqttDeviceWithAccessToken() throws Exception {
Device device = new Device();
device.setName("My device");
device.setDeviceProfileId(mqttDeviceProfileId);
Device savedDevice = doPost("/api/device", device, Device.class);
DeviceCredentials credentials =
doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class);
JsonNode commands =
doGetTyped("/api/device-connectivity/" + savedDevice.getId().getId(), new TypeReference<>() {
});
assertThat(commands).hasSize(1);
JsonNode mqttCommands = commands.get(MQTT);
assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t %s " +
"-u %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o tb-server-chain.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download");
assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile tb-server-chain.pem -h localhost -p 8883 " +
"-t %s -u %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER);
assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" +
" -p 1883 -t %s -u %s -m \"{temperature:25}\"",
DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " +
"/bin/sh -c \"curl -f -S -o tb-server-chain.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " +
"mosquitto_pub -d -q 1 --cafile tb-server-chain.pem -h localhost -p 8883 -t %s -u %s -m \"{temperature:25}\"\"",
DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
}
@Test
public void testFetchPublishTelemetryCommandsForDeviceWithMqttBasicCreds() throws Exception {
Device device = new Device();
device.setName("My device");
device.setDeviceProfileId(mqttDeviceProfileId);
Device savedDevice = doPost("/api/device", device, Device.class);
DeviceCredentials credentials =
doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class);
credentials.setCredentialsId(null);
credentials.setCredentialsType(DeviceCredentialsType.MQTT_BASIC);
BasicMqttCredentials basicMqttCredentials = new BasicMqttCredentials();
String clientId = "testClientId";
String userName = "testUsername";
String password = "testPassword";
basicMqttCredentials.setClientId(clientId);
basicMqttCredentials.setUserName(userName);
basicMqttCredentials.setPassword(password);
credentials.setCredentialsValue(JacksonUtil.toString(basicMqttCredentials));
doPost("/api/device/credentials", credentials)
.andExpect(status().isOk());
JsonNode commands =
doGetTyped("/api/device-connectivity/" + savedDevice.getId().getId(), new TypeReference<>() {
});
assertThat(commands).hasSize(1);
JsonNode mqttCommands = commands.get(MQTT);
assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t %s " +
"-i %s -u %s -P %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o tb-server-chain.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download");
assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile tb-server-chain.pem -h localhost -p 8883 " +
"-t %s -i %s -u %s -P %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER);
assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" +
" -p 1883 -t %s -i %s -u %s -P %s -m \"{temperature:25}\"",
DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " +
"/bin/sh -c \"curl -f -S -o tb-server-chain.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " +
"mosquitto_pub -d -q 1 --cafile tb-server-chain.pem -h localhost -p 8883 -t %s -i %s -u %s -P %s -m \"{temperature:25}\"\"",
DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
}
@Test
public void testFetchPublishTelemetryCommandsForDeviceWithX509Creds() throws Exception {
Device device = new Device();
device.setName("My device");
device.setDeviceProfileId(mqttDeviceProfileId);
Device savedDevice = doPost("/api/device", device, Device.class);
DeviceCredentials credentials =
doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class);
credentials.setCredentialsId(null);
credentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE);
credentials.setCredentialsValue("testValue");
doPost("/api/device/credentials", credentials)
.andExpect(status().isOk());
JsonNode commands =
doGetTyped("/api/device-connectivity/" + savedDevice.getId().getId(), new TypeReference<>() {
});
assertThat(commands).hasSize(1);
assertThat(commands.get(MQTT).get(MQTTS).asText()).isEqualTo(CHECK_DOCUMENTATION);
assertThat(commands.get(MQTT).get(DOCKER)).isNull();
}
@Test
public void testFetchPublishTelemetryCommandsForCoapDevice() throws Exception {
Device device = new Device();
device.setName("My device");
device.setDeviceProfileId(coapDeviceProfileId);
Device savedDevice = doPost("/api/device", device, Device.class);
DeviceCredentials credentials =
doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class);
JsonNode commands =
doGetTyped("/api/device-connectivity/" + savedDevice.getId().getId(), new TypeReference<>() {
});
assertThat(commands).hasSize(1);
JsonNode linuxCommands = commands.get(COAP);
assertThat(linuxCommands.get(COAP).asText()).isEqualTo(String.format("coap-client -m POST coap://localhost:5683/api/v1/%s/telemetry -t json -e \"{temperature:25}\"",
credentials.getCredentialsId()));
assertThat(linuxCommands.get(COAPS).asText()).isEqualTo(String.format("coap-client-openssl -m POST coaps://localhost:5684/api/v1/%s/telemetry -t json -e \"{temperature:25}\"",
credentials.getCredentialsId()));
}
@Test
public void testFetchPublishTelemetryCommandsForCoapDeviceWithX509Creds() throws Exception {
Device device = new Device();
device.setName("My device");
device.setDeviceProfileId(coapDeviceProfileId);
Device savedDevice = doPost("/api/device", device, Device.class);
DeviceCredentials credentials =
doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class);
credentials.setCredentialsId(null);
credentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE);
credentials.setCredentialsValue("testValue");
doPost("/api/device/credentials", credentials)
.andExpect(status().isOk());
JsonNode commands =
doGetTyped("/api/device-connectivity/" + savedDevice.getId().getId(), new TypeReference<>() {
});
assertThat(commands).hasSize(1);
assertThat(commands.get(COAP).get(COAPS).asText()).isEqualTo(CHECK_DOCUMENTATION);
}
}

160
application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java

@ -0,0 +1,160 @@
/**
* Copyright © 2016-2023 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.controller.plugin;
import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.adapter.NativeWebSocketSession;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.service.ws.WebSocketSessionRef;
import javax.websocket.RemoteEndpoint;
import javax.websocket.SendHandler;
import javax.websocket.SendResult;
import javax.websocket.Session;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.BDDMockito.willDoNothing;
import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@Slf4j
class TbWebSocketHandlerTest {
TbWebSocketHandler wsHandler;
NativeWebSocketSession session;
Session nativeSession;
RemoteEndpoint.Async asyncRemote;
WebSocketSessionRef sessionRef;
int maxMsgQueuePerSession;
TbWebSocketHandler.SessionMetaData sendHandler;
ExecutorService executor;
@BeforeEach
void setUp() throws IOException {
maxMsgQueuePerSession = 100;
executor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName(getClass().getSimpleName()));
wsHandler = spy(new TbWebSocketHandler());
willDoNothing().given(wsHandler).close(any(), any());
session = mock(NativeWebSocketSession.class);
nativeSession = mock(Session.class);
willReturn(nativeSession).given(session).getNativeSession(Session.class);
asyncRemote = mock(RemoteEndpoint.Async.class);
willReturn(asyncRemote).given(nativeSession).getAsyncRemote();
sessionRef = mock(WebSocketSessionRef.class, Mockito.RETURNS_DEEP_STUBS); //prevent NPE on logs
sendHandler = spy(wsHandler.new SessionMetaData(session, sessionRef, maxMsgQueuePerSession));
}
@AfterEach
void tearDown() {
if (executor != null) {
executor.shutdownNow();
}
}
@Test
void sendHandler_sendMsg_parallel_no_race() throws InterruptedException {
CountDownLatch finishLatch = new CountDownLatch(maxMsgQueuePerSession * 2);
AtomicInteger sendersCount = new AtomicInteger();
willAnswer(invocation -> {
assertThat(sendersCount.incrementAndGet()).as("no race").isEqualTo(1);
String text = invocation.getArgument(0);
SendHandler onResultHandler = invocation.getArgument(1);
SendResult sendResult = new SendResult();
executor.submit(() -> {
sendersCount.decrementAndGet();
onResultHandler.onResult(sendResult);
finishLatch.countDown();
});
return null;
}).given(asyncRemote).sendText(anyString(), any());
assertThat(sendHandler.isSending.get()).as("sendHandler not is in sending state").isFalse();
//first batch
IntStream.range(0, maxMsgQueuePerSession).parallel().forEach(i -> sendHandler.sendMsg("hello " + i));
Awaitility.await("first batch processed").atMost(30, TimeUnit.SECONDS).until(() -> finishLatch.getCount() == maxMsgQueuePerSession);
assertThat(sendHandler.isSending.get()).as("sendHandler not is in sending state").isFalse();
//second batch - to test pause between big msg batches
IntStream.range(100, 100 + maxMsgQueuePerSession).parallel().forEach(i -> sendHandler.sendMsg("hello " + i));
assertThat(finishLatch.await(30, TimeUnit.SECONDS)).as("all callbacks fired").isTrue();
verify(sendHandler, never()).closeSession(any());
verify(sendHandler, times(maxMsgQueuePerSession * 2)).onResult(any());
assertThat(sendHandler.isSending.get()).as("sendHandler not is in sending state").isFalse();
}
@Test
void sendHandler_sendMsg_message_order() throws InterruptedException {
CountDownLatch finishLatch = new CountDownLatch(maxMsgQueuePerSession);
Collection<String> outputs = new ConcurrentLinkedQueue<>();
willAnswer(invocation -> {
String text = invocation.getArgument(0);
outputs.add(text);
SendHandler onResultHandler = invocation.getArgument(1);
SendResult sendResult = new SendResult();
executor.submit(() -> {
onResultHandler.onResult(sendResult);
finishLatch.countDown();
});
return null;
}).given(asyncRemote).sendText(anyString(), any());
List<String> inputs = IntStream.range(0, maxMsgQueuePerSession).mapToObj(i -> "msg " + i).collect(Collectors.toList());
inputs.forEach(s -> sendHandler.sendMsg(s));
assertThat(finishLatch.await(30, TimeUnit.SECONDS)).as("all callbacks fired").isTrue();
assertThat(outputs).as("inputs exactly the same as outputs").containsExactlyElementsOf(inputs);
verify(sendHandler, never()).closeSession(any());
verify(sendHandler, times(maxMsgQueuePerSession)).onResult(any());
}
@Test
void sendHandler_sendMsg_queue_size_exceed() {
willDoNothing().given(asyncRemote).sendText(anyString(), any()); // send text will never call back, so queue will grow each sendMsg
sendHandler.sendMsg("first message to stay in-flight all the time during this test");
IntStream.range(0, maxMsgQueuePerSession).parallel().forEach(i -> sendHandler.sendMsg("hello " + i));
verify(sendHandler, never()).closeSession(any());
sendHandler.sendMsg("excessive message");
verify(sendHandler, times(1)).closeSession(eq(new CloseStatus(1008, "Max pending updates limit reached!")));
verify(asyncRemote, times(1)).sendText(anyString(), any());
}
}

29
common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2023 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.dao.device;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.core.io.Resource;
import org.thingsboard.server.common.data.Device;
import java.net.URISyntaxException;
public interface DeviceConnectivityService {
JsonNode findDevicePublishTelemetryCommands(String baseUrl, Device device) throws URISyntaxException;
Resource getPemCertFile(String protocol);
}

42
common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java

@ -16,6 +16,7 @@
package org.thingsboard.server.queue.discovery;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.ProtocolStringList;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
@ -44,8 +45,10 @@ import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@ -66,6 +69,10 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
private Integer zkSessionTimeout;
@Value("${zk.zk_dir}")
private String zkDir;
@Value("${zk.recalculate_delay:60000}")
private Long recalculateDelay;
protected final ConcurrentHashMap<String, ScheduledFuture<?>> delayedTasks;
private final TbServiceInfoProvider serviceInfoProvider;
private final PartitionService partitionService;
@ -82,6 +89,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
PartitionService partitionService) {
this.serviceInfoProvider = serviceInfoProvider;
this.partitionService = partitionService;
delayedTasks = new ConcurrentHashMap<>();
}
@PostConstruct
@ -287,11 +295,39 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
log.error("Failed to decode server instance for node {}", data.getPath(), e);
throw e;
}
log.debug("Processing [{}] event for [{}]", pathChildrenCacheEvent.getType(), instance.getServiceId());
String serviceId = instance.getServiceId();
ProtocolStringList serviceTypesList = instance.getServiceTypesList();
log.trace("Processing [{}] event for [{}]", pathChildrenCacheEvent.getType(), serviceId);
switch (pathChildrenCacheEvent.getType()) {
case CHILD_ADDED:
ScheduledFuture<?> task = delayedTasks.remove(serviceId);
if (task != null) {
if (task.cancel(false)) {
log.debug("[{}] Recalculate partitions ignored. Service was restarted in time [{}].",
serviceId, serviceTypesList);
} else {
log.debug("[{}] Going to recalculate partitions. Service was not restarted in time [{}]!",
serviceId, serviceTypesList);
recalculatePartitions();
}
} else {
log.trace("[{}] Going to recalculate partitions due to adding new node [{}].",
serviceId, serviceTypesList);
recalculatePartitions();
}
break;
case CHILD_REMOVED:
recalculatePartitions();
ScheduledFuture<?> future = zkExecutorService.schedule(() -> {
log.debug("[{}] Going to recalculate partitions due to removed node [{}]",
serviceId, serviceTypesList);
ScheduledFuture<?> removedTask = delayedTasks.remove(serviceId);
if (removedTask != null) {
recalculatePartitions();
}
}, recalculateDelay, TimeUnit.MILLISECONDS);
delayedTasks.put(serviceId, future);
break;
default:
break;
@ -303,6 +339,8 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
* Synchronized to ensure that other servers info is up to date
* */
synchronized void recalculatePartitions() {
delayedTasks.values().forEach(future -> future.cancel(false));
delayedTasks.clear();
partitionService.recalculatePartitions(serviceInfoProvider.getServiceInfo(), getOtherServers());
}

189
common/queue/src/test/java/org/thingsboard/server/queue/discovery/ZkDiscoveryServiceTest.java

@ -0,0 +1,189 @@
/**
* Copyright © 2016-2023 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.queue.discovery;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.imps.CuratorFrameworkState;
import org.apache.curator.framework.recipes.cache.ChildData;
import org.apache.curator.framework.recipes.cache.PathChildrenCache;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.gen.transport.TransportProtos;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import static org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent.Type.CHILD_ADDED;
import static org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent.Type.CHILD_REMOVED;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class ZkDiscoveryServiceTest {
@Mock
private TbServiceInfoProvider serviceInfoProvider;
@Mock
private PartitionService partitionService;
@Mock
private CuratorFramework client;
@Mock
private PathChildrenCache cache;
@Mock
private CuratorFramework curatorFramework;
private ZkDiscoveryService zkDiscoveryService;
private static final long RECALCULATE_DELAY = 100L;
final TransportProtos.ServiceInfo currentInfo = TransportProtos.ServiceInfo.newBuilder().setServiceId("tb-rule-engine-0").build();
final ChildData currentData = new ChildData("/thingsboard/nodes/0000000010", null, currentInfo.toByteArray());
final TransportProtos.ServiceInfo childInfo = TransportProtos.ServiceInfo.newBuilder().setServiceId("tb-rule-engine-1").build();
final ChildData childData = new ChildData("/thingsboard/nodes/0000000020", null, childInfo.toByteArray());
@Before
public void setup() {
zkDiscoveryService = Mockito.spy(new ZkDiscoveryService(serviceInfoProvider, partitionService));
ScheduledExecutorService zkExecutorService = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("zk-discovery"));
when(client.getState()).thenReturn(CuratorFrameworkState.STARTED);
ReflectionTestUtils.setField(zkDiscoveryService, "stopped", false);
ReflectionTestUtils.setField(zkDiscoveryService, "client", client);
ReflectionTestUtils.setField(zkDiscoveryService, "cache", cache);
ReflectionTestUtils.setField(zkDiscoveryService, "nodePath", "/thingsboard/nodes/0000000010");
ReflectionTestUtils.setField(zkDiscoveryService, "zkExecutorService", zkExecutorService);
ReflectionTestUtils.setField(zkDiscoveryService, "recalculateDelay", RECALCULATE_DELAY);
ReflectionTestUtils.setField(zkDiscoveryService, "zkDir", "/thingsboard");
when(serviceInfoProvider.getServiceInfo()).thenReturn(currentInfo);
List<ChildData> dataList = new ArrayList<>();
dataList.add(currentData);
when(cache.getCurrentData()).thenReturn(dataList);
}
@Test
public void restartNodeInTimeTest() throws Exception {
startNode(childData);
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(List.of(childInfo)));
reset(partitionService);
stopNode(childData);
assertEquals(1, zkDiscoveryService.delayedTasks.size());
verify(partitionService, never()).recalculatePartitions(any(), any());
startNode(childData);
verify(partitionService, never()).recalculatePartitions(any(), any());
Thread.sleep(RECALCULATE_DELAY * 2);
verify(partitionService, never()).recalculatePartitions(any(), any());
assertTrue(zkDiscoveryService.delayedTasks.isEmpty());
}
@Test
public void restartNodeNotInTimeTest() throws Exception {
startNode(childData);
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(List.of(childInfo)));
reset(partitionService);
stopNode(childData);
assertEquals(1, zkDiscoveryService.delayedTasks.size());
Thread.sleep(RECALCULATE_DELAY * 2);
assertTrue(zkDiscoveryService.delayedTasks.isEmpty());
startNode(childData);
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(Collections.emptyList()));
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(List.of(childInfo)));
reset(partitionService);
}
@Test
public void startAnotherNodeDuringRestartTest() throws Exception {
var anotherInfo = TransportProtos.ServiceInfo.newBuilder().setServiceId("tb-transport").build();
var anotherData = new ChildData("/thingsboard/nodes/0000000030", null, anotherInfo.toByteArray());
startNode(childData);
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(List.of(childInfo)));
reset(partitionService);
stopNode(childData);
assertEquals(1, zkDiscoveryService.delayedTasks.size());
startNode(anotherData);
assertTrue(zkDiscoveryService.delayedTasks.isEmpty());
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(List.of(anotherInfo)));
reset(partitionService);
Thread.sleep(RECALCULATE_DELAY * 2);
verify(partitionService, never()).recalculatePartitions(any(), any());
startNode(childData);
verify(partitionService, times(1)).recalculatePartitions(eq(currentInfo), eq(List.of(anotherInfo, childInfo)));
}
private void startNode(ChildData data) throws Exception {
cache.getCurrentData().add(data);
zkDiscoveryService.childEvent(curatorFramework, new PathChildrenCacheEvent(CHILD_ADDED, data));
}
private void stopNode(ChildData data) throws Exception {
cache.getCurrentData().remove(data);
zkDiscoveryService.childEvent(curatorFramework, new PathChildrenCacheEvent(CHILD_REMOVED, data));
}
}

7
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java

@ -66,8 +66,6 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem
protected final Map<UUID, String> scriptIdToHash = new ConcurrentHashMap<>();
protected final Map<String, TbelScript> scriptMap = new ConcurrentHashMap<>();
private final String tbelSwitch = "switch";
private final String tbelSwitchErrorMsg = "TBEL does not support the 'switch'.";
protected Cache<String, Serializable> compiledScriptsCache;
private SandboxedParserConfiguration parserConfig;
@ -183,11 +181,6 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem
lock.unlock();
}
return scriptId;
} catch (CompileException ce) {
if ( ce.getExpr() != null && new String(ce.getExpr()).contains(tbelSwitch)) {
ce = new CompileException(tbelSwitchErrorMsg, ce.getExpr(), ce.getCursor(), ce.getCause());
}
throw new TbScriptException(scriptId, TbScriptException.ErrorCode.COMPILATION, scriptBody, ce);
} catch (Exception e) {
throw new TbScriptException(scriptId, TbScriptException.ErrorCode.COMPILATION, scriptBody, e);
}

8
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java

@ -52,7 +52,7 @@ import java.util.stream.Collectors;
public class LwM2MModelConfigServiceImpl implements LwM2MModelConfigService {
@Autowired
private TbLwM2MModelConfigStore modelStore;
TbLwM2MModelConfigStore modelStore;
@Autowired
@Lazy
@ -67,14 +67,14 @@ public class LwM2MModelConfigServiceImpl implements LwM2MModelConfigService {
@Autowired
private LwM2MTelemetryLogService logService;
private ConcurrentMap<String, LwM2MModelConfig> currentModelConfigs;
ConcurrentMap<String, LwM2MModelConfig> currentModelConfigs;
@AfterStartUp(order = AfterStartUp.BEFORE_TRANSPORT_SERVICE)
private void init() {
public void init() {
List<LwM2MModelConfig> models = modelStore.getAll();
log.debug("Fetched model configs: {}", models);
currentModelConfigs = models.stream()
.collect(Collectors.toConcurrentMap(LwM2MModelConfig::getEndpoint, m -> m));
.collect(Collectors.toConcurrentMap(LwM2MModelConfig::getEndpoint, m -> m, (existing, replacement) -> existing));
}
@Override

73
common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImplTest.java

@ -0,0 +1,73 @@
/**
* Copyright © 2016-2023 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.transport.lwm2m.server.model;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.transport.lwm2m.server.store.TbLwM2MModelConfigStore;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.mock;
class LwM2MModelConfigServiceImplTest {
LwM2MModelConfigServiceImpl service;
TbLwM2MModelConfigStore modelStore;
@BeforeEach
void setUp() {
service = new LwM2MModelConfigServiceImpl();
modelStore = mock(TbLwM2MModelConfigStore.class);
service.modelStore = modelStore;
}
@Test
void testInitWithDuplicatedModels() {
LwM2MModelConfig config = new LwM2MModelConfig("urn:imei:951358811362976");
List<LwM2MModelConfig> models = List.of(config, config);
willReturn(models).given(modelStore).getAll();
service.init();
assertThat(service.currentModelConfigs).containsExactlyEntriesOf(Map.of(config.getEndpoint(), config));
}
@Test
void testInitWithNonUniqueEndpoints() {
LwM2MModelConfig configAlfa = new LwM2MModelConfig("urn:imei:951358811362976");
LwM2MModelConfig configBravo = new LwM2MModelConfig("urn:imei:151358811362976");
LwM2MModelConfig configDelta = new LwM2MModelConfig("urn:imei:151358811362976");
assertThat(configBravo.getEndpoint()).as("non-unique endpoints provided").isEqualTo(configDelta.getEndpoint());
List<LwM2MModelConfig> models = List.of(configAlfa, configBravo, configDelta);
willReturn(models).given(modelStore).getAll();
service.init();
assertThat(service.currentModelConfigs).containsExactlyInAnyOrderEntriesOf(Map.of(
configAlfa.getEndpoint(), configAlfa,
configBravo.getEndpoint(), configBravo
));
}
@Test
void testInitWithEmptyModels() {
willReturn(Collections.emptyList()).given(modelStore).getAll();
service.init();
assertThat(service.currentModelConfigs).isEmpty();
}
}

34
dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityConfiguration.java

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2023 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.dao.device;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
@Configuration
@ConfigurationProperties(prefix = "device")
@Data
public class DeviceConnectivityConfiguration {
private Map<String, DeviceConnectivityInfo> connectivity;
public boolean isEnabled(String protocol) {
var info = connectivity.get(protocol);
return info != null && info.isEnabled();
}
}

26
dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityInfo.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2023 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.dao.device;
import lombok.Data;
@Data
public class DeviceConnectivityInfo {
private boolean enabled;
private String host;
private String port;
private String pemCertFile;
}

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

@ -0,0 +1,268 @@
/**
* Copyright © 2016-2023 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.dao.device;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceTransportType;
import org.thingsboard.server.common.data.ResourceUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.dao.util.DeviceConnectivityUtil;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.CHECK_DOCUMENTATION;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.COAP;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.COAPS;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.DOCKER;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.HTTP;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.HTTPS;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.MQTT;
import static org.thingsboard.server.dao.util.DeviceConnectivityUtil.MQTTS;
@Service("DeviceConnectivityDaoService")
@Slf4j
public class DeviceConnectivityServiceImpl implements DeviceConnectivityService {
public static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
public static final String INCORRECT_DEVICE_ID = "Incorrect deviceId ";
public static final String DEFAULT_DEVICE_TELEMETRY_TOPIC = "v1/devices/me/telemetry";
@Autowired
private DeviceCredentialsService deviceCredentialsService;
@Autowired
private DeviceProfileService deviceProfileService;
@Autowired
private DeviceConnectivityConfiguration deviceConnectivityConfiguration;
@Override
public JsonNode findDevicePublishTelemetryCommands(String baseUrl, Device device) throws URISyntaxException {
DeviceId deviceId = device.getId();
log.trace("Executing findDevicePublishTelemetryCommands [{}]", deviceId);
validateId(deviceId, INCORRECT_DEVICE_ID + deviceId);
DeviceCredentials creds = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), deviceId);
DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(device.getTenantId(), device.getDeviceProfileId());
DeviceTransportType transportType = deviceProfile.getTransportType();
ObjectNode commands = JacksonUtil.newObjectNode();
switch (transportType) {
case DEFAULT:
Optional.ofNullable(getHttpTransportPublishCommands(baseUrl, creds))
.ifPresent(v -> commands.set(HTTP, v));
Optional.ofNullable(getMqttTransportPublishCommands(baseUrl, creds))
.ifPresent(v -> commands.set(MQTT, v));
Optional.ofNullable(getCoapTransportPublishCommands(baseUrl, creds))
.ifPresent(v -> commands.set(COAP, v));
break;
case MQTT:
MqttDeviceProfileTransportConfiguration transportConfiguration =
(MqttDeviceProfileTransportConfiguration) deviceProfile.getProfileData().getTransportConfiguration();
//TODO: add sparkplug command with emulator (check SSL)
if (transportConfiguration.isSparkplug()) {
ObjectNode sparkplug = JacksonUtil.newObjectNode();
sparkplug.put("sparkplug", CHECK_DOCUMENTATION);
commands.set(MQTT, sparkplug);
} else {
String topicName = transportConfiguration.getDeviceTelemetryTopic();
Optional.ofNullable(getMqttTransportPublishCommands(baseUrl, topicName, creds))
.ifPresent(v -> commands.set(MQTT, v));
}
break;
case COAP:
Optional.ofNullable(getCoapTransportPublishCommands(baseUrl, creds))
.ifPresent(v -> commands.set(COAP, v));
break;
default:
commands.put(transportType.name(), CHECK_DOCUMENTATION);
}
return commands;
}
@Override
public Resource getPemCertFile(String protocol) {
String certFilePath = deviceConnectivityConfiguration.getConnectivity()
.get(protocol)
.getPemCertFile();
if (StringUtils.isNotBlank(certFilePath) && ResourceUtils.resourceExists(this, certFilePath)) {
return new ClassPathResource(certFilePath);
} else {
return null;
}
}
private JsonNode getHttpTransportPublishCommands(String defaultHostname, DeviceCredentials deviceCredentials) throws URISyntaxException {
ObjectNode httpCommands = JacksonUtil.newObjectNode();
Optional.ofNullable(getHttpPublishCommand(HTTP, defaultHostname, deviceCredentials))
.ifPresent(v -> httpCommands.put(HTTP, v));
Optional.ofNullable(getHttpPublishCommand(HTTPS, defaultHostname, deviceCredentials))
.ifPresent(v -> httpCommands.put(HTTPS, v));
return httpCommands.isEmpty() ? null : httpCommands;
}
private String getHttpPublishCommand(String protocol, String baseUrl, DeviceCredentials deviceCredentials) throws URISyntaxException {
DeviceConnectivityInfo properties = deviceConnectivityConfiguration.getConnectivity().get(protocol);
if (properties == null || !properties.isEnabled() ||
deviceCredentials.getCredentialsType() != DeviceCredentialsType.ACCESS_TOKEN) {
return null;
}
String hostName = getHost(baseUrl, properties);
String port = properties.getPort().isEmpty() ? "" : ":" + properties.getPort();
return DeviceConnectivityUtil.getHttpPublishCommand(protocol, hostName, port, deviceCredentials);
}
private JsonNode getMqttTransportPublishCommands(String baseUrl, DeviceCredentials deviceCredentials) throws URISyntaxException {
return getMqttTransportPublishCommands(baseUrl, DEFAULT_DEVICE_TELEMETRY_TOPIC, deviceCredentials);
}
private JsonNode getMqttTransportPublishCommands(String baseUrl, String topic, DeviceCredentials deviceCredentials) throws URISyntaxException {
ObjectNode mqttCommands = JacksonUtil.newObjectNode();
if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) {
mqttCommands.put(MQTTS, CHECK_DOCUMENTATION);
return mqttCommands;
}
ObjectNode dockerMqttCommands = JacksonUtil.newObjectNode();
if (deviceConnectivityConfiguration.isEnabled(MQTT)) {
Optional.ofNullable(getMqttPublishCommand(baseUrl, topic, deviceCredentials)).
ifPresent(v -> mqttCommands.put(MQTT, v));
Optional.ofNullable(getDockerMqttPublishCommand(MQTT, baseUrl, topic, deviceCredentials))
.ifPresent(v -> dockerMqttCommands.put(MQTT, v));
}
if (deviceConnectivityConfiguration.isEnabled(MQTTS)) {
List<String> mqttsPublishCommand = getMqttsPublishCommand(baseUrl, topic, deviceCredentials);
if (mqttsPublishCommand != null) {
ArrayNode arrayNode = mqttCommands.putArray(MQTTS);
mqttsPublishCommand.forEach(arrayNode::add);
}
Optional.ofNullable(getDockerMqttPublishCommand(MQTTS, baseUrl, topic, deviceCredentials))
.ifPresent(v -> dockerMqttCommands.put(MQTTS, v));
}
if (!dockerMqttCommands.isEmpty()) {
mqttCommands.set(DOCKER, dockerMqttCommands);
}
return mqttCommands.isEmpty() ? null : mqttCommands;
}
private String getMqttPublishCommand(String baseUrl, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) throws URISyntaxException {
DeviceConnectivityInfo properties = deviceConnectivityConfiguration.getConnectivity().get(MQTT);
String mqttHost = getHost(baseUrl, properties);
String mqttPort = properties.getPort().isEmpty() ? null : properties.getPort();
return DeviceConnectivityUtil.getMqttPublishCommand(MQTT, mqttHost, mqttPort, deviceTelemetryTopic, deviceCredentials);
}
private List<String> getMqttsPublishCommand(String baseUrl, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) throws URISyntaxException {
DeviceConnectivityInfo properties = deviceConnectivityConfiguration.getConnectivity().get(MQTTS);
String mqttHost = getHost(baseUrl, properties);
String mqttPort = properties.getPort().isEmpty() ? null : properties.getPort();
String pubCommand = DeviceConnectivityUtil.getMqttPublishCommand(MQTTS, mqttHost, mqttPort, deviceTelemetryTopic, deviceCredentials);
ArrayList<String> commands = new ArrayList<>();
if (pubCommand != null) {
commands.add(DeviceConnectivityUtil.getCurlPemCertCommand(baseUrl, MQTTS));
commands.add(pubCommand);
return commands;
}
return null;
}
private String getDockerMqttPublishCommand(String protocol, String baseUrl, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) throws URISyntaxException {
DeviceConnectivityInfo properties = deviceConnectivityConfiguration.getConnectivity().get(protocol);
String mqttHost = getHost(baseUrl, properties);
String mqttPort = properties.getPort().isEmpty() ? null : properties.getPort();
return DeviceConnectivityUtil.getDockerMqttPublishCommand(protocol, baseUrl, mqttHost, mqttPort, deviceTelemetryTopic, deviceCredentials);
}
private JsonNode getCoapTransportPublishCommands(String baseUrl, DeviceCredentials deviceCredentials) throws URISyntaxException {
ObjectNode coapCommands = JacksonUtil.newObjectNode();
if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) {
coapCommands.put(COAPS, CHECK_DOCUMENTATION);
return coapCommands;
}
ObjectNode dockerCoapCommands = JacksonUtil.newObjectNode();
if (deviceConnectivityConfiguration.isEnabled(COAP)) {
Optional.ofNullable(getCoapPublishCommand(COAP, baseUrl, deviceCredentials))
.ifPresent(v -> coapCommands.put(COAP, v));
Optional.ofNullable(getDockerCoapPublishCommand(COAP, baseUrl, deviceCredentials))
.ifPresent(v -> dockerCoapCommands.put(COAP, v));
}
if (deviceConnectivityConfiguration.isEnabled(COAPS)) {
Optional.ofNullable(getCoapPublishCommand(COAPS, baseUrl, deviceCredentials))
.ifPresent(v -> coapCommands.put(COAPS, v));
Optional.ofNullable(getDockerCoapPublishCommand(COAPS, baseUrl, deviceCredentials))
.ifPresent(v -> dockerCoapCommands.put(COAPS, v));
}
if (!dockerCoapCommands.isEmpty()) {
coapCommands.set(DOCKER, dockerCoapCommands);
}
return coapCommands.isEmpty() ? null : coapCommands;
}
private String getCoapPublishCommand(String protocol, String baseUrl, DeviceCredentials deviceCredentials) throws URISyntaxException {
DeviceConnectivityInfo properties = deviceConnectivityConfiguration.getConnectivity().get(protocol);
String hostName = getHost(baseUrl, properties);
String port = properties.getPort().isEmpty() ? "" : ":" + properties.getPort();
return DeviceConnectivityUtil.getCoapPublishCommand(protocol, hostName, port, deviceCredentials);
}
private String getDockerCoapPublishCommand(String protocol, String baseUrl, DeviceCredentials deviceCredentials) throws URISyntaxException {
DeviceConnectivityInfo properties = deviceConnectivityConfiguration.getConnectivity().get(protocol);
String host = getHost(baseUrl, properties);
String port = properties.getPort().isEmpty() ? "" : ":" + properties.getPort();
return DeviceConnectivityUtil.getDockerCoapPublishCommand(protocol, host, port, deviceCredentials);
}
private String getHost(String baseUrl, DeviceConnectivityInfo properties) throws URISyntaxException {
return properties.getHost().isEmpty() ? new URI(baseUrl).getHost() : properties.getHost();
}
}

9
dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java

@ -19,13 +19,13 @@ import com.google.common.base.Function;
import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.springframework.beans.factory.annotation.Value;
import org.thingsboard.common.util.ThingsBoardExecutors;
import javax.annotation.Nullable;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Created by ashvayka on 21.02.17.
@ -34,9 +34,12 @@ public abstract class CassandraAbstractAsyncDao extends CassandraAbstractDao {
protected ExecutorService readResultsProcessingExecutor;
@Value("${cassandra.query.result_processing_threads:50}")
private int threadPoolSize;
@PostConstruct
public void startExecutor() {
readResultsProcessingExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("cassandra-callback"));
readResultsProcessingExecutor = ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "cassandra-callback");
}
@PreDestroy

16
dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java

@ -121,7 +121,7 @@ public abstract class AbstractBufferedRateExecutor<T extends AsyncTask, F extend
perTenantLimitReached = true;
}
} else if (tenantId == null) {
log.info("Invalid task received: {}", task);
log.info("[{}] Invalid task received: {}", getBufferName(), task);
}
if (!perTenantLimitReached) {
@ -157,7 +157,7 @@ public abstract class AbstractBufferedRateExecutor<T extends AsyncTask, F extend
public abstract String getBufferName();
private void dispatch() {
log.info("Buffered rate executor thread started");
log.info("[{}] Buffered rate executor thread started", getBufferName());
while (!Thread.interrupted()) {
int curLvl = concurrencyLevel.get();
AsyncTaskContext<T, V> taskCtx = null;
@ -169,7 +169,7 @@ public abstract class AbstractBufferedRateExecutor<T extends AsyncTask, F extend
if (printQueriesIdx.incrementAndGet() >= printQueriesFreq) {
printQueriesIdx.set(0);
String query = queryToString(finalTaskCtx);
log.info("[{}] Cassandra query: {}", taskCtx.getId(), query);
log.info("[{}][{}] Cassandra query: {}", getBufferName(), taskCtx.getId(), query);
}
}
logTask("Processing", finalTaskCtx);
@ -222,7 +222,7 @@ public abstract class AbstractBufferedRateExecutor<T extends AsyncTask, F extend
}
}
}
log.info("Buffered rate executor thread stopped");
log.info("[{}] Buffered rate executor thread stopped", getBufferName());
}
private void logTask(String action, AsyncTaskContext<T, V> taskCtx) {
@ -298,7 +298,7 @@ public abstract class AbstractBufferedRateExecutor<T extends AsyncTask, F extend
statsBuilder.append(CONCURRENCY_LEVEL).append(" = [").append(concurrencyLevel.get()).append("] ");
stats.getStatsCounters().forEach(StatsCounter::clear);
log.info("Permits {}", statsBuilder);
log.info("[{}] Permits {}", getBufferName(), statsBuilder);
}
stats.getRateLimitedTenants().entrySet().stream()
@ -314,13 +314,13 @@ public abstract class AbstractBufferedRateExecutor<T extends AsyncTask, F extend
try {
return entityService.fetchEntityName(TenantId.SYS_TENANT_ID, tenantId).orElse(defaultName);
} catch (Exception e) {
log.error("[{}] Failed to get tenant name", tenantId, e);
log.error("[{}][{}] Failed to get tenant name", getBufferName(), tenantId, e);
return defaultName;
}
});
log.info("[{}][{}] Rate limited requests: {}", tenantId, name, rateLimitedRequests);
log.info("[{}][{}][{}] Rate limited requests: {}", getBufferName(), tenantId, name, rateLimitedRequests);
} else {
log.info("[{}] Rate limited requests: {}", tenantId, rateLimitedRequests);
log.info("[{}][{}] Rate limited requests: {}", getBufferName(), tenantId, rateLimitedRequests);
}
});
}

123
dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java

@ -0,0 +1,123 @@
/**
* Copyright © 2016-2023 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.dao.util;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentials;
public class DeviceConnectivityUtil {
public static final String HTTP = "http";
public static final String HTTPS = "https";
public static final String MQTT = "mqtt";
public static final String LINUX = "linux";
public static final String WINDOWS = "windows";
public static final String DOCKER = "docker";
public static final String MQTTS = "mqtts";
public static final String COAP = "coap";
public static final String COAPS = "coaps";
public static final String PEM_CERT_FILE_NAME = "tb-server-chain.pem";
public static final String CHECK_DOCUMENTATION = "Check documentation";
public static final String JSON_EXAMPLE_PAYLOAD = "\"{temperature:25}\"";
public static final String DOCKER_RUN = "docker run --rm -it ";
public static final String MQTT_IMAGE = "thingsboard/mosquitto-clients ";
public static final String COAP_IMAGE = "thingsboard/coap-clients ";
public static String getHttpPublishCommand(String protocol, String host, String port, DeviceCredentials deviceCredentials) {
return String.format("curl -v -X POST %s://%s%s/api/v1/%s/telemetry --header Content-Type:application/json --data " + JSON_EXAMPLE_PAYLOAD,
protocol, host, port, deviceCredentials.getCredentialsId());
}
public static String getMqttPublishCommand(String protocol, String host, String port, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) {
StringBuilder command = new StringBuilder("mosquitto_pub -d -q 1");
if (MQTTS.equals(protocol)) {
command.append(" --cafile ").append(PEM_CERT_FILE_NAME);
}
command.append(" -h ").append(host).append(port == null ? "" : " -p " + port);
command.append(" -t ").append(deviceTelemetryTopic);
switch (deviceCredentials.getCredentialsType()) {
case ACCESS_TOKEN:
command.append(" -u ").append(deviceCredentials.getCredentialsId());
break;
case MQTT_BASIC:
BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(),
BasicMqttCredentials.class);
if (credentials != null) {
if (credentials.getClientId() != null) {
command.append(" -i ").append(credentials.getClientId());
}
if (credentials.getUserName() != null) {
command.append(" -u ").append(credentials.getUserName());
}
if (credentials.getPassword() != null) {
command.append(" -P ").append(credentials.getPassword());
}
} else {
return null;
}
break;
default:
return null;
}
command.append(" -m " + JSON_EXAMPLE_PAYLOAD);
return command.toString();
}
public static String getDockerMqttPublishCommand(String protocol, String baseUrl, String host, String port, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) {
String mqttCommand = getMqttPublishCommand(protocol, host, port, deviceTelemetryTopic, deviceCredentials);
if (mqttCommand == null) {
return null;
}
StringBuilder mqttDockerCommand = new StringBuilder();
mqttDockerCommand.append(DOCKER_RUN).append(MQTT_IMAGE);
if (MQTTS.equals(protocol)) {
mqttDockerCommand.append("/bin/sh -c \"")
.append(getCurlPemCertCommand(baseUrl, protocol))
.append(" && ")
.append(mqttCommand)
.append("\"");
} else {
mqttDockerCommand.append(mqttCommand);
}
return mqttDockerCommand.toString();
}
public static String getCurlPemCertCommand(String baseUrl, String protocol) {
return String.format("curl -f -S -o %s %s/api/device-connectivity/%s/certificate/download", PEM_CERT_FILE_NAME, baseUrl, protocol);
}
public static String getCoapPublishCommand(String protocol, String host, String port, DeviceCredentials deviceCredentials) {
switch (deviceCredentials.getCredentialsType()) {
case ACCESS_TOKEN:
String client = COAPS.equals(protocol) ? "coap-client-openssl" : "coap-client";
return String.format("%s -m POST %s://%s%s/api/v1/%s/telemetry -t json -e %s",
client, protocol, host, port, deviceCredentials.getCredentialsId(), JSON_EXAMPLE_PAYLOAD);
default:
return null;
}
}
public static String getDockerCoapPublishCommand(String protocol, String host, String port, DeviceCredentials deviceCredentials) {
String coapCommand = getCoapPublishCommand(protocol, host, port, deviceCredentials);
return coapCommand != null ? String.format("%s%s%s", DOCKER_RUN, COAP_IMAGE, coapCommand) : null;
}
}

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

@ -41,6 +41,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
queue:
type: "${TB_QUEUE_TYPE:kafka}" # in-memory or kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ)

2
pom.xml

@ -78,7 +78,7 @@
<zookeeper.version>3.8.1</zookeeper.version>
<protobuf.version>3.21.9</protobuf.version>
<grpc.version>1.42.1</grpc.version>
<tbel.version>1.0.6</tbel.version>
<tbel.version>1.0.7</tbel.version>
<lombok.version>1.18.18</lombok.version>
<paho.client.version>1.2.4</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>

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

@ -41,6 +41,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
cache:
type: "${CACHE_TYPE:redis}"

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

@ -68,6 +68,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
cache:
type: "${CACHE_TYPE:redis}"

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

@ -41,6 +41,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
cache:
type: "${CACHE_TYPE:redis}"

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

@ -41,6 +41,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
cache:
type: "${CACHE_TYPE:redis}"

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

@ -41,6 +41,7 @@ zk:
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:60000}"
cache:
type: "${CACHE_TYPE:redis}"

6
ui-ngx/src/app/app.component.ts

@ -30,7 +30,7 @@ import { combineLatest } from 'rxjs';
import { selectIsAuthenticated, selectIsUserLoaded } from '@core/auth/auth.selectors';
import { distinctUntilChanged, filter, map, skip } from 'rxjs/operators';
import { AuthService } from '@core/auth/auth.service';
import { svgIcons } from '@shared/models/icon.models';
import { svgIcons, svgIconsUrl } from '@shared/models/icon.models';
@Component({
selector: 'tb-root',
@ -65,6 +65,10 @@ export class AppComponent implements OnInit {
);
}
for (const svgIcon of Object.keys(svgIconsUrl)) {
this.matIconRegistry.addSvgIcon(svgIcon, this.domSanitizer.bypassSecurityTrustResourceUrl(svgIconsUrl[svgIcon]));
}
this.storageService.testLocalStorage();
this.setupTranslate();

19
ui-ngx/src/app/core/auth/auth.actions.ts

@ -17,6 +17,7 @@
import { Action } from '@ngrx/store';
import { User } from '@shared/models/user.model';
import { AuthPayload } from '@core/auth/auth.models';
import { UserSettings } from '@shared/models/user-settings.models';
export enum AuthActionTypes {
AUTHENTICATED = '[Auth] Authenticated',
@ -25,7 +26,9 @@ export enum AuthActionTypes {
UPDATE_USER_DETAILS = '[Auth] Update User Details',
UPDATE_LAST_PUBLIC_DASHBOARD_ID = '[Auth] Update Last Public Dashboard Id',
UPDATE_HAS_REPOSITORY = '[Auth] Change Has Repository',
UPDATE_OPENED_MENU_SECTION = '[Preferences] Update Opened Menu Section'
UPDATE_OPENED_MENU_SECTION = '[Preferences] Update Opened Menu Section',
PUT_USER_SETTINGS = '[Preferences] Put user settings',
DELETE_USER_SETTINGS = '[Preferences] Delete user settings',
}
export class ActionAuthAuthenticated implements Action {
@ -68,6 +71,18 @@ export class ActionPreferencesUpdateOpenedMenuSection implements Action {
constructor(readonly payload: { path: string; opened: boolean }) {}
}
export class ActionPreferencesPutUserSettings implements Action {
readonly type = AuthActionTypes.PUT_USER_SETTINGS;
constructor(readonly payload: Partial<UserSettings>) {}
}
export class ActionPreferencesDeleteUserSettings implements Action {
readonly type = AuthActionTypes.DELETE_USER_SETTINGS;
constructor(readonly payload: Array<NestedKeyOf<UserSettings>>) {}
}
export type AuthActions = ActionAuthAuthenticated | ActionAuthUnauthenticated |
ActionAuthLoadUser | ActionAuthUpdateUserDetails | ActionAuthUpdateLastPublicDashboardId | ActionAuthUpdateHasRepository |
ActionPreferencesUpdateOpenedMenuSection;
ActionPreferencesUpdateOpenedMenuSection | ActionPreferencesPutUserSettings | ActionPreferencesDeleteUserSettings;

14
ui-ngx/src/app/core/auth/auth.effects.ts

@ -39,4 +39,18 @@ export class AuthEffects {
withLatestFrom(this.store.pipe(select(selectAuthState))),
mergeMap(([action, state]) => this.userSettingsService.putUserSettings({ openedMenuSections: state.userSettings.openedMenuSections }))
), {dispatch: false});
putUserSettings = createEffect(() => this.actions$.pipe(
ofType(
AuthActionTypes.PUT_USER_SETTINGS,
),
mergeMap((state) => this.userSettingsService.putUserSettings(state.payload))
), {dispatch: false});
deleteUserSettings = createEffect(() => this.actions$.pipe(
ofType(
AuthActionTypes.DELETE_USER_SETTINGS,
),
mergeMap((state) => this.userSettingsService.deleteUserSettings(state.payload))
), {dispatch: false});
}

15
ui-ngx/src/app/core/auth/auth.reducer.ts

@ -16,7 +16,8 @@
import { AuthPayload, AuthState } from './auth.models';
import { AuthActions, AuthActionTypes } from './auth.actions';
import { initialUserSettings } from '@shared/models/user-settings.models';
import { initialUserSettings, UserSettings } from '@shared/models/user-settings.models';
import { unset } from '@core/utils';
const emptyUserAuthState: AuthPayload = {
authUser: null,
@ -42,6 +43,7 @@ export const authReducer = (
state: AuthState = initialState,
action: AuthActions
): AuthState => {
let userSettings: UserSettings;
switch (action.type) {
case AuthActionTypes.AUTHENTICATED:
return { ...state, isAuthenticated: true, ...action.payload };
@ -71,7 +73,16 @@ export const authReducer = (
} else {
openedMenuSections.delete(action.payload.path);
}
const userSettings = {...state.userSettings, ...{ openedMenuSections: Array.from(openedMenuSections)}};
userSettings = {...state.userSettings, ...{ openedMenuSections: Array.from(openedMenuSections)}};
return { ...state, ...{ userSettings }};
case AuthActionTypes.PUT_USER_SETTINGS:
userSettings = {...state.userSettings, ...action.payload};
return { ...state, ...{ userSettings }};
case AuthActionTypes.DELETE_USER_SETTINGS:
userSettings = {...state.userSettings};
action.payload.forEach(path => unset(userSettings, path));
return { ...state, ...{ userSettings }};
default:

6
ui-ngx/src/app/core/auth/auth.selectors.ts

@ -21,6 +21,7 @@ import { AuthState } from './auth.models';
import { take } from 'rxjs/operators';
import { AuthUser } from '@shared/models/user.model';
import { UserSettings } from '@shared/models/user-settings.models';
import { getDescendantProp } from '@core/utils';
export const selectAuthState = createFeatureSelector< AuthState>(
'auth'
@ -76,6 +77,11 @@ export const selectUserSettings = createSelector(
(state: AuthState) => state.userSettings
);
export const selectUserSettingsProperty = (path: NestedKeyOf<UserSettings>) => createSelector(
selectAuthState,
(state: AuthState) => getDescendantProp(state.userSettings, path)
);
export const selectOpenedMenuSections = createSelector(
selectAuthState,
(state: AuthState) => state.userSettings.openedMenuSections

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

@ -244,7 +244,7 @@ export class AuthService {
if (authState && authState.authUser) {
if (authState.authUser.authority === Authority.TENANT_ADMIN || authState.authUser.authority === Authority.CUSTOMER_USER) {
if ((this.userHasDefaultDashboard(authState) && authState.forceFullscreen) || authState.authUser.isPublic) {
if (path === 'profile' || path === 'security') {
if (path.startsWith('account')) {
if (this.userHasProfile(authState.authUser)) {
return false;
} else {

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

@ -25,8 +25,10 @@ import {
ClaimResult,
Device,
DeviceCredentials,
DeviceInfo, DeviceInfoQuery,
DeviceSearchQuery
DeviceInfo,
DeviceInfoQuery,
DeviceSearchQuery,
PublishTelemetryCommand
} from '@app/shared/models/device.models';
import { EntitySubtype } from '@app/shared/models/entity-type.models';
import { AuthService } from '@core/auth/auth.service';
@ -208,4 +210,8 @@ export class DeviceService {
return this.http.post<BulkImportResult>('/api/device/bulk_import', entitiesData, defaultHttpOptionsFromConfig(config));
}
public getDevicePublishTelemetryCommands(deviceId: string, config?: RequestConfig): Observable<PublishTelemetryCommand> {
return this.http.get<PublishTelemetryCommand>(`/api/device-connectivity/${deviceId}`, defaultHttpOptionsFromConfig(config));
}
}

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

@ -348,10 +348,8 @@ export class DashboardUtilsService {
private convertDatasourcesFromWidgetType(widgetTypeDescriptor: WidgetTypeDescriptor,
config: WidgetConfig, datasources?: Datasource[]): Datasource[] {
const newDatasources: Datasource[] = [];
if (datasources) {
datasources.forEach(datasource => {
newDatasources.push(this.convertDatasourceFromWidgetType(widgetTypeDescriptor, config, datasource));
});
if (datasources?.length) {
newDatasources.push(this.convertDatasourceFromWidgetType(widgetTypeDescriptor, config, datasources[0]));
}
return newDatasources;
}

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

@ -315,6 +315,8 @@ export const isEqual = (a: any, b: any): boolean => _.isEqual(a, b);
export const isEmpty = (a: any): boolean => _.isEmpty(a);
export const unset = (object: any, path: string | symbol): boolean => _.unset(object, path);
export const isEqualIgnoreUndefined = (a: any, b: any): boolean => {
if (a === b) {
return true;
@ -774,3 +776,25 @@ export function genNextLabel(name: string, datasources: Datasource[]): string {
}
return label;
}
export const getOS = (): string => {
const userAgent = window.navigator.userAgent.toLowerCase();
const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos|mac_powerpc)/i;
const windowsPlatforms = /(win32|win64|windows|wince)/i;
const iosPlatforms = /(iphone|ipad|ipod|darwin|ios)/i;
let os = null;
if (macosPlatforms.test(userAgent)) {
os = 'macos';
} else if (iosPlatforms.test(userAgent)) {
os = 'ios';
} else if (windowsPlatforms.test(userAgent)) {
os = 'windows';
} else if (/android/.test(userAgent)) {
os = 'android';
} else if (/linux/.test(userAgent)) {
os = 'linux';
}
return os;
};

22
ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.html

@ -56,25 +56,28 @@
</ng-template>
<ng-template #alarmFilter>
<div class="tb-form-panel no-padding no-border" [formGroup]="alarmFilterConfigForm">
<div class="tb-form-row space-between">
<div class="fixed-title-width" translate>alarm.alarm-status-list</div>
<div class="tb-form-row space-between" ngClass.xs="filters-row-mobile">
<div class="fixed-title-width" ngClass.xs="filters-title-mobile" translate>alarm.alarm-status-list</div>
<mat-chip-listbox multiple formControlName="statusList">
<mat-chip-option *ngFor="let searchStatus of alarmSearchStatuses" [value]="searchStatus">
{{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }}
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" translate>alarm.alarm-severity-list</div>
<div class="tb-form-row space-between" ngClass.xs="filters-row-mobile">
<div class="fixed-title-width" ngClass.xs="filters-title-mobile" translate>alarm.alarm-severity-list</div>
<mat-chip-listbox multiple formControlName="severityList">
<mat-chip-option *ngFor="let alarmSeverity of alarmSeverities" [value]="alarmSeverity">
{{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }}
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" translate>alarm.alarm-type-list</div>
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic" class="tb-chips">
<div class="tb-form-row" ngClass.xs="filters-row-mobile">
<div class="fixed-title-width" ngClass.xs="filters-title-mobile" translate>alarm.alarm-type-list</div>
<mat-form-field fxFlex appearance="outline"
subscriptSizing="dynamic"
class="tb-chips"
ngClass.xs="filters-fields-width-mobile">
<mat-chip-grid #alarmTypeChipList formControlName="typeList">
<mat-chip-row *ngFor="let type of alarmTypeList()"
[removable]="true" (removed)="removeAlarmType(type)">
@ -89,9 +92,10 @@
</mat-chip-grid>
</mat-form-field>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" translate>alarm.assignee</div>
<div class="tb-form-row" ngClass.xs="filters-row-mobile">
<div class="fixed-title-width" ngClass.xs="filters-title-mobile" translate>alarm.assignee</div>
<tb-alarm-assignee-select fxFlex inline="true"
ngClass.xs="filters-fields-width-mobile"
[userMode]="userMode"
formControlName="assigneeId">
</tb-alarm-assignee-select>

20
ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.scss

@ -15,11 +15,24 @@
*/
:host {
display: block;
overflow: hidden;
overflow: scroll;
max-width: 100%;
.mdc-button {
max-width: 100%;
}
.filters-row-mobile {
flex-direction: column;
align-items: start;
border: none;
padding: 0;
}
.filters-title-mobile {
font-size: 14px;
}
.filters-fields-width-mobile {
width: 100%;
}
}
:host ::ng-deep {
@ -32,4 +45,9 @@
text-overflow: ellipsis;
}
}
.mat-mdc-chip {
.mdc-evolution-chip__cell, .mat-mdc-chip-action, .mat-mdc-chip-action-label {
overflow: hidden;
}
}
}

2
ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss

@ -104,7 +104,7 @@
}
mat-cell.tb-value-cell {
cursor: pointer;
mat-icon {
.mat-icon {
height: 24px;
width: 24px;
font-size: 24px;

18
ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html

@ -17,14 +17,16 @@
-->
<form class="tb-add-widget-dialog" [formGroup]="widgetFormGroup">
<mat-toolbar color="primary">
<h2 translate>widget.add</h2>
<span fxFlex>: {{data.widgetInfo.widgetName}}</span>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px">
<tb-toggle-select *ngIf="hasBasicMode" appearance="fill-invert" [(ngModel)]="widgetConfigMode" [ngModelOptions]="{standalone: true}">
<tb-toggle-option value="basic">{{ 'widget.basic-mode' | translate }}</tb-toggle-option>
<tb-toggle-option value="advanced">{{ 'widget.advanced-mode' | translate }}</tb-toggle-option>
</tb-toggle-select>
<div [tb-help]="helpLinkIdForWidgetType()"></div>
<div class="tb-add-widget-toolbar">
<h2 fxFlex>{{'widget.add' | translate}}: {{data.widgetInfo.widgetName}}</h2>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap.gt-xs="16px">
<tb-toggle-select *ngIf="hasBasicMode" appearance="fill-invert" selectMediaBreakpoint="xs"
[(ngModel)]="widgetConfigMode" [ngModelOptions]="{standalone: true}">
<tb-toggle-option value="basic">{{ 'widget.basic-mode' | translate }}</tb-toggle-option>
<tb-toggle-option value="advanced">{{ 'widget.advanced-mode' | translate }}</tb-toggle-option>
</tb-toggle-select>
<div [tb-help]="helpLinkIdForWidgetType()"></div>
</div>
</div>
<button mat-icon-button
(click)="cancel()"

13
ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.scss

@ -21,9 +21,18 @@
position: relative;
}
@media #{$mat-gt-xs} {
width: 1200px;
width: 900px;
.mat-mdc-dialog-content {
height: 600px;
height: 900px;
}
}
.tb-add-widget-toolbar {
flex: 1;
min-width: 0;
display: flex;
flex-direction: row;
place-content: center flex-start;
align-items: center;
gap: 8px;
}
}

25
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -25,8 +25,7 @@
[toolbarOpened]="toolbarOpened" (triggerClick)="openToolbar()">
<div class="tb-dashboard-action-panels" fxLayout="column" fxLayout.gt-sm="row"
fxLayoutAlign="center stretch" fxLayoutAlign.gt-sm="space-between center">
<div class="tb-dashboard-action-panel" fxFlex="auto" fxLayout="row-reverse" [fxHide]="isMobileApp && !isEdit"
fxLayoutAlign.gt-sm="end center" fxLayoutAlign="space-between center" fxLayoutGap.lt-lg="6px" fxLayoutGap.gt-md="12px">
<div class="tb-dashboard-action-panel tb-left-panel" [fxHide]="isMobileApp && !isEdit">
<tb-user-menu *ngIf="!isPublicUser() && forceFullscreen" fxHide.gt-sm displayUserInfo="true">
</tb-user-menu>
<ng-container *ngIf="isEdit">
@ -74,8 +73,7 @@
<img *ngIf="showDashboardLogo()" [src]="dashboardLogo"
aria-label="dashboard_logo" class="dashboard_logo"/>
</div>
<div class="tb-dashboard-action-panel" fxFlex="1 0 auto" fxLayout="row-reverse"
fxLayoutAlign.gt-sm="start center" fxLayoutAlign="space-between center" fxLayoutGap.lt-lg="6px" fxLayoutGap.gt-md="12px">
<div class="tb-dashboard-action-panel">
<button [fxShow]="showCloseToolbar()" mat-icon-button class="close-action"
matTooltip="{{ 'dashboard.close-toolbar' | translate }}"
matTooltipPosition="below"
@ -235,8 +233,21 @@
</tb-timewindow>
</ng-container>
</ng-template>
<button [fxShow]="isEdit" mat-stroked-button (click)="addWidget($event)">
<mat-icon>add</mat-icon>{{ 'dashboard.add-widget-button-text' | translate }}</button>
<ng-container *ngIf="isEdit">
<button fxHide.gt-xs mat-icon-button
matTooltip="{{ 'dashboard.add-widget-button-text' | translate }}"
matTooltipPosition="below"
(click)="addWidget($event)">
<mat-icon>add</mat-icon>
</button>
<button fxHide.lt-sm mat-stroked-button
matTooltip="{{ 'dashboard.add-widget-button-text' | translate }}"
matTooltipPosition="below"
(click)="addWidget($event)">
<mat-icon>add</mat-icon>
{{ 'dashboard.add-widget-button-text' | translate }}
</button>
</ng-container>
<tb-filters-edit [fxShow]="!isEdit && displayFilters()"
tooltipPosition="below"
[aliasController]="dashboardCtx.aliasController">
@ -360,7 +371,7 @@
[isReadOnly]="true"
(closeDetails)="onEditWidgetClosed()">
<div class="details-buttons" fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px">
<tb-toggle-select *ngIf="tbEditWidget.hasBasicMode" appearance="fill-invert" [(ngModel)]="tbEditWidget.widgetConfigMode">
<tb-toggle-select *ngIf="tbEditWidget.hasBasicMode" appearance="fill-invert" selectMediaBreakpoint="xs" [(ngModel)]="tbEditWidget.widgetConfigMode">
<tb-toggle-option value="basic">{{ 'widget.basic-mode' | translate }}</tb-toggle-option>
<tb-toggle-option value="advanced">{{ 'widget.advanced-mode' | translate }}</tb-toggle-option>
</tb-toggle-select>

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

@ -191,7 +191,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
}
get hideToolbar(): boolean {
return (this.hideToolbarValue || this.hideToolbarSetting()) && !this.isEdit;
return ((this.hideToolbarValue || this.hideToolbarSetting()) && !this.isEdit) || (this.isEditingWidget || this.isAddingWidget);
}
@Input()
@ -1177,6 +1177,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
Widget>(AddWidgetDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
maxWidth: '95vw',
data: {
dashboard: this.dashboard,
aliasController: this.dashboardCtx.aliasController,

37
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss

@ -126,6 +126,7 @@ tb-dashboard-toolbar {
@media #{$mat-lt-md} {
height: $mobile-toolbar-height;
max-height: $mobile-toolbar-height;
padding: 0 8px !important;
}
.close-action {
@ -150,8 +151,44 @@ tb-dashboard-toolbar {
.tb-dashboard-action-panel {
min-width: 0;
height: $half-mobile-toolbar-height;
flex: 1 0 auto;
display: flex;
flex-direction: row-reverse;
place-content: center space-between;
align-items: center;
&.tb-left-panel {
flex: 1 1 auto;
}
@media #{$mat-lt-md} {
padding-left: 12px;
}
@media #{$mat-xs} {
gap: 3px;
padding-left: 0;
&.tb-left-panel {
padding-left: 12px;
}
}
@media #{$mat-sm} {
gap: 6px;
}
@media #{$mat-md} {
gap: 6px;
}
@media #{$mat-gt-md} {
gap: 12px;
}
@media #{$mat-gt-sm} {
place-content: center flex-start;
&.tb-left-panel {
place-content: center flex-end;
}
height: 46px;
}

2
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss

@ -45,6 +45,8 @@
}
.preview {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;

44
ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html

@ -32,26 +32,38 @@
<mat-icon>chevron_left</mat-icon>
{{ 'action.back' | translate }}
</button>
</div>
<div class="tb-widget-config-header-suffix" fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<button *ngIf="!previewMode" [disabled]="(isLoading$ | async) || widgetFormGroup.invalid"
mat-button color="primary"
(click)="previewMode = true">
<button *ngIf="!previewMode"
fxHide.gt-sm
[disabled]="widgetFormGroup.invalid"
mat-button color="primary"
(click)="previewMode = true">
<mat-icon>visibility</mat-icon>
{{ 'widget-config.preview' | translate }}
</button>
<button [disabled]="(isLoading$ | async) || !widgetFormGroup.dirty"
mat-stroked-button color="primary"
(click)="onRevertWidgetConfig()">
<mat-icon>close</mat-icon>
{{ 'action.decline' | translate }}
</button>
<button [disabled]="(isLoading$ | async) || widgetFormGroup.invalid || !widgetFormGroup.dirty"
mat-flat-button color="primary"
(click)="onApplyWidgetConfig()">
<mat-icon>done</mat-icon>
{{ 'action.apply' | translate }}
</div>
<div fxFlex.lt-md class="tb-widget-config-header-suffix" fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="12px">
<button *ngIf="!previewMode"
fxHide.lt-md
[disabled]="widgetFormGroup.invalid"
mat-button color="primary"
(click)="previewMode = true">
<mat-icon>visibility</mat-icon>
{{ 'widget-config.preview' | translate }}
</button>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<button [disabled]="!widgetFormGroup.dirty"
mat-stroked-button color="primary"
(click)="onRevertWidgetConfig()">
<mat-icon>close</mat-icon>
{{ 'action.decline' | translate }}
</button>
<button [disabled]="widgetFormGroup.invalid || !widgetFormGroup.dirty"
mat-flat-button color="primary"
(click)="onApplyWidgetConfig()">
<mat-icon>done</mat-icon>
{{ 'action.apply' | translate }}
</button>
</div>
</div>
</tb-widget-config>
<div *ngIf="previewMode" class="widget-preview-background">

2
ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.scss

@ -16,7 +16,7 @@
:host {
.widget-preview-background {
position: absolute;
top: 72px;
top: 68px;
left: 0;
right: 0;
bottom: 0;

2
ui-ngx/src/app/modules/home/components/details-panel.component.html

@ -21,7 +21,7 @@
<div class="mat-toolbar-tools tb-details-title-header" fxFlex fxLayout="column" fxLayoutAlign="start start" [fxHide]="showSearchPane">
<div class="tb-details-title" fxLayout="row" fxLayoutAlign="start center">
<ng-content select=".prefix-title-buttons"></ng-content>
<span>{{ headerTitle }}</span>
<span class="tb-details-title-text">{{ headerTitle }}</span>
</div>
<span class="tb-details-subtitle">{{ headerSubtitle }}</span>
<span style="width: 100%;">

13
ui-ngx/src/app/modules/home/components/details-panel.component.scss

@ -32,16 +32,14 @@
max-height: 120px;
&.tb-details-title-header {
min-width: 0;
padding: 0 16px 0 8px;
}
}
.tb-details-title {
width: inherit;
margin: 20px 8px 0 0;
overflow: hidden;
font-size: 1rem;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
@media #{$mat-gt-sm} {
font-size: 1.5rem;
@ -49,13 +47,16 @@
}
.tb-details-subtitle {
width: inherit;
margin: 10px 0;
overflow: hidden;
font-size: 1rem;
opacity: .8;
}
.tb-details-title-text, .tb-details-subtitle {
width: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: .8;
}
tb-dashboard {

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

@ -360,7 +360,7 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
this.cellActionDescriptors.push({
name: this.translate.instant('rulenode.test-with-this-message', {test: this.translate.instant(this.testButtonLabel)}),
icon: 'bug_report',
isEnabled: (entity) => entity.body.type === 'IN',
isEnabled: (entity) => entity.body.type === 'IN' || entity.body.error !== undefined,
onAction: ($event, entity) => {
this.debugEventSelected.next(entity.body);
}

1
ui-ngx/src/app/modules/home/components/router-tabs.component.html

@ -20,6 +20,7 @@
<nav fxFlex *ngIf="!routerOutlet?.activatedRouteData?.hideTabs && !hideCurrentTabs && (tabs$ | async).length > 1" mat-tab-nav-bar mat-stretch-tabs="false" class="tb-router-tabs" [tabPanel]="tabPanel">
<a *ngFor="let tab of tabs$ | async"
routerLink="{{tab.path}}"
replaceUrl="{{ replaceUrl }}"
routerLinkActive
#rla="routerLinkActive"
mat-tab-link

62
ui-ngx/src/app/modules/home/components/router-tabs.component.ts

@ -20,8 +20,8 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { MenuService } from '@core/services/menu.service';
import { distinctUntilChanged, filter, map, mergeMap, take } from 'rxjs/operators';
import { merge } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, startWith, take } from 'rxjs/operators';
import { merge, Observable } from 'rxjs';
import { MenuSection } from '@core/services/menu.models';
import { ActiveComponentService } from '@core/services/active-component.service';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
@ -39,14 +39,9 @@ export class RouterTabsComponent extends PageComponent implements OnInit {
hideCurrentTabs = false;
tabs$ = merge(this.menuService.menuSections(),
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd ),
distinctUntilChanged())
).pipe(
mergeMap(() => this.menuService.menuSections().pipe(take(1))),
map((sections) => this.buildTabs(this.activatedRoute, sections))
);
replaceUrl = false;
tabs$: Observable<Array<MenuSection>>;
constructor(protected store: Store<AppState>,
private activatedRoute: ActivatedRoute,
@ -57,6 +52,27 @@ export class RouterTabsComponent extends PageComponent implements OnInit {
}
ngOnInit() {
if (this.activatedRoute.snapshot.data.useChildrenRoutesForTabs) {
this.tabs$ = this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
startWith(''),
map(() => this.buildTabsForRoutes(this.activatedRoute))
);
} else {
this.tabs$ = merge(this.menuService.menuSections(),
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd ),
distinctUntilChanged())
).pipe(
mergeMap(() => this.menuService.menuSections().pipe(take(1))),
map((sections) => this.buildTabs(this.activatedRoute, sections))
);
}
if (this.activatedRoute.snapshot.data.replaceUrl) {
this.replaceUrl = true;
}
this.activatedRoute.data.subscribe(
(data) => this.buildTabsHeaderComponent(data)
);
@ -80,18 +96,36 @@ export class RouterTabsComponent extends PageComponent implements OnInit {
}
}
private buildTabs(activatedRoute: ActivatedRoute, sections: MenuSection[]): Array<MenuSection> {
const sectionPath = '/' + activatedRoute.pathFromRoot.map(r => r.snapshot.url)
private getSectionPath(activatedRoute: ActivatedRoute): string {
return '/' + activatedRoute.pathFromRoot.map(r => r.snapshot.url)
.filter(f => !!f[0]).map(f => f.map(f1 => f1.path).join('/')).join('/');
}
private buildTabs(activatedRoute: ActivatedRoute, sections: MenuSection[]): Array<MenuSection> {
const sectionPath = this.getSectionPath(activatedRoute);
const found = this.findRootSection(sections, sectionPath);
if (found) {
const rootPath = sectionPath.substring(0, sectionPath.length - found.path.length);
const isRoot = rootPath === '';
const tabs: Array<MenuSection> = found ? found.pages.filter(page => !page.disabled && (!page.rootOnly || isRoot)) : [];
return tabs.map((tab) => ({...tab, path: rootPath + tab.path}));
} else {
return [];
}
return [];
}
private buildTabsForRoutes(activatedRoute: ActivatedRoute): Array<MenuSection> {
const sectionPath = this.getSectionPath(activatedRoute);
if (activatedRoute.routeConfig.children.length) {
const activeRouterChildren = activatedRoute.routeConfig.children.filter(page => page.path !== '');
return activeRouterChildren.map(tab => ({
id: tab.component.name,
type: 'link',
name: tab.data?.breadcrumb?.label ?? '',
icon: tab.data?.breadcrumb?.icon ?? '',
path: `${sectionPath}/${tab.path}`
}));
}
return [];
}
private findRootSection(sections: MenuSection[], sectionPath: string): MenuSection {

2
ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.html

@ -72,7 +72,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="search">{{ 'action.search' | translate }}</mat-chip-option>

6
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-config.scss

@ -13,8 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../scss/constants';
:host {
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-xs} {
gap: 8px;
}
}

2
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/entities-table-basic-config.component.html

@ -61,7 +61,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="search">{{ 'action.search' | translate }}</mat-chip-option>

2
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.html

@ -61,7 +61,7 @@
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>

2
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/timeseries-table-basic-config.component.html

@ -61,7 +61,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="search">{{ 'action.search' | translate }}</mat-chip-option>

46
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html

@ -55,7 +55,7 @@
</tb-color-settings>
</div>
</div>
<div class="tb-form-row">
<div [fxShow]="iconEnabled" class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.value-card.icon' | translate }}
</mat-slide-toggle>
@ -87,18 +87,38 @@
</tb-color-settings>
</div>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-date-format-select fxFlex formControlName="dateFormat"></tb-date-format-select>
<tb-font-settings formControlName="dateFont"
[previewText]="datePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="dateColor">
</tb-color-settings>
<div [fxShow]="dateEnabled" class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-date-format-select fxFlex formControlName="dateFormat"></tb-date-format-select>
<tb-font-settings formControlName="dateFont"
[previewText]="datePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="dateColor">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-border-radius' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

10
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts

@ -74,6 +74,16 @@ export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent {
datePreviewFn = this._datePreviewFn.bind(this);
get dateEnabled(): boolean {
const layout: ValueCardLayout = this.valueCardWidgetConfigForm.get('layout').value;
return ![ValueCardLayout.vertical, ValueCardLayout.simplified].includes(layout);
}
get iconEnabled(): boolean {
const layout: ValueCardLayout = this.valueCardWidgetConfigForm.get('layout').value;
return layout !== ValueCardLayout.simplified;
}
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private cd: ChangeDetectorRef,

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

@ -61,7 +61,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>

9
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html

@ -22,7 +22,7 @@
<mat-option [value]="true">{{ 'datakey.latest' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex class="tb-inline-field tb-key-field" subscriptSizing="dynamic">
<mat-form-field class="tb-inline-field tb-key-field" subscriptSizing="dynamic">
<mat-chip-grid #chipList>
<mat-chip-row class="tb-datakey-chip" *ngIf="modelValue.type"
(removed)="removeKey()">
@ -44,7 +44,7 @@
matTooltipPosition="above">timeline</mat-icon>
</ng-container>
</div>
<div class="tb-chip-label">
<div class="tb-chip-label tb-chip-text">
<strong>
<ng-container *ngTemplateOutlet="keyName"></ng-container>
</strong>
@ -139,7 +139,7 @@
</mat-option>
</mat-autocomplete>
</mat-form-field>
<mat-form-field fxFlex class="tb-inline-field" appearance="outline" subscriptSizing="dynamic">
<mat-form-field class="tb-inline-field tb-label-field" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<div *ngIf="!hideDataKeyColor" class="tb-color-field">
@ -158,7 +158,8 @@
</mat-form-field>
</div>
<div class="tb-form-table-row-cell-buttons">
<button type="button"
<button fxHide.lt-lg
type="button"
mat-icon-button
(click)="editKey(true)"
[matTooltip]="keySettingsTitle"

37
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss

@ -13,9 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../../scss/constants';
.tb-data-key-row {
.mat-mdc-form-field.tb-inline-field.tb-key-field {
.mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) {
padding-left: 8px;
padding-right: 0;
.mat-mdc-form-field-infix {
padding-top: 0;
padding-bottom: 6px;
@ -38,7 +43,21 @@
}
.tb-source-field {
width: 140px;
width: 108px;
min-width: 108px;
}
.tb-key-field {
flex: 1;
min-width: 150px;
@media #{$mat-gt-sm} {
flex: 1 1 60%;
}
}
.tb-label-field {
flex: 1 1 40%;
min-width: 100px;
}
.tb-color-field, .tb-units-field, .tb-decimals-field {
@ -50,9 +69,23 @@
.tb-units-field {
width: 80px;
min-width: 80px;
}
.tb-color-field {
width: 40px;
min-width: 40px;
}
.tb-color-field, .tb-decimals-field {
.tb-decimals-field {
width: 60px;
min-width: 60px;
}
.tb-label-field, .tb-units-field, .tb-color-field, .tb-decimals-field {
display: none;
@media #{$mat-gt-sm} {
display: block;
}
}
}

7
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html

@ -20,8 +20,8 @@
<div class="tb-form-table">
<div class="tb-form-table-header">
<div *ngIf="hasAdditionalLatestDataKeys" class="tb-form-table-header-cell tb-source-header" translate>datakey.source</div>
<div class="tb-form-table-header-cell" fxFlex translate>datakey.key</div>
<div class="tb-form-table-header-cell" fxFlex translate>datakey.label</div>
<div class="tb-form-table-header-cell tb-key-header" translate>datakey.key</div>
<div class="tb-form-table-header-cell tb-label-header" translate>datakey.label</div>
<div *ngIf="!hideDataKeyColor" class="tb-form-table-header-cell tb-color-header" translate>datakey.color</div>
<div *ngIf="!hideUnits" class="tb-form-table-header-cell tb-units-header" translate>widget-config.units-short</div>
<div *ngIf="!hideDecimals" class="tb-form-table-header-cell tb-decimals-header" translate>widget-config.decimals-short</div>
@ -42,7 +42,8 @@
(keyRemoved)="removeKey($index)">
</tb-data-key-row>
<div class="tb-form-table-row-cell-buttons">
<button mat-icon-button
<button fxHide.lt-lg
mat-icon-button
type="button"
cdkDragHandle
[ngClass]="{'tb-hidden': !dragEnabled}"

44
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.scss

@ -13,17 +13,55 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../../scss/constants';
.tb-form-table-header-cell {
&.tb-source-header {
width: 140px;
width: 108px;
min-width: 108px;
}
&.tb-key-header {
flex: 1;
min-width: 150px;
@media #{$mat-gt-sm} {
flex: 1 1 60%;
}
}
&.tb-label-header {
flex: 1 1 40%;
min-width: 100px;
}
&.tb-units-header {
width: 80px;
min-width: 80px;
}
&.tb-color-header {
width: 40px;
min-width: 40px;
}
&.tb-color-header, &.tb-decimals-header {
&.tb-decimals-header {
width: 60px;
min-width: 60px;
}
&.tb-actions-header {
width: 114px;
width: 40px;
min-width: 40px;
@media #{$mat-gt-md} {
width: 120px;
min-width: 120px;
}
}
&.tb-label-header, &.tb-units-header, &.tb-color-header, &.tb-decimals-header {
display: none;
@media #{$mat-gt-sm} {
display: block;
}
}
}
.tb-form-table-body {
tb-data-key-row {
overflow: hidden;
}
}

2
ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html

@ -65,7 +65,7 @@
<span *ngIf="!hideDataKeyLabel && key.label !== key.name">{{key.label}}</span>
</div>
<div *ngIf="!hideDataKeyLabel && key.label !== key.name" class="tb-chip-separator">: </div>
<div class="tb-chip-label">
<div class="tb-chip-label tb-chip-text">
<strong>
<ng-container *ngTemplateOutlet="keyName; context:{key: key}"></ng-container>
</strong>

10
ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.scss

@ -49,7 +49,7 @@
padding: 3px;
height: 24px;
cursor: move;
mat-icon {
.mat-icon {
pointer-events: none;
}
}
@ -65,9 +65,11 @@
font-weight: normal;
font-size: 14px;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.tb-chip-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-icon.tb-datakey-icon {
margin-right: 4px;
margin-left: 4px;

11
ui-ngx/src/app/modules/home/components/widget/config/timewindow-config-panel.component.html

@ -18,22 +18,23 @@
<div [formGroup]="timewindowConfig" class="tb-form-panel">
<div fxLayout="row" fxLayoutAlign="space-between center">
<div class="tb-form-panel-title" translate>timewindow.timewindow</div>
<tb-toggle-select formControlName="useDashboardTimewindow">
<tb-toggle-select formControlName="useDashboardTimewindow" selectMediaBreakpoint="xs">
<tb-toggle-option [value]="true">{{ 'widget-config.use-dashboard-timewindow' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="false">{{ 'widget-config.use-widget-timewindow' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-form-row">
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide" formControlName="displayTimewindow">
{{ 'widget-config.display-timewindow' | translate }}
</mat-slide-toggle>
<tb-timewindow asButton="true"
strokedButton
noMargin
isEdit="true"
alwaysDisplayTypePrefix
[historyOnly]="onlyHistoryTimewindow"
quickIntervalOnly="{{ widgetType === widgetTypes.latest }}"
aggregation="{{ widgetType === widgetTypes.timeseries }}"
formControlName="timewindow"></tb-timewindow>
<mat-slide-toggle class="mat-slide" formControlName="displayTimewindow">
{{ 'widget-config.display-timewindow' | translate }}
</mat-slide-toggle>
</div>
</div>

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

@ -44,6 +44,7 @@ import { widgetSettingsComponentsMap } from '@home/components/widget/lib/setting
import { Dashboard } from '@shared/models/dashboard.models';
import { WidgetService } from '@core/http/widget.service';
import { IAliasController } from '@core/api/widget-api.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
@Component({
selector: 'tb-widget-settings',
@ -73,6 +74,9 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnInit, On
@Input()
widget: Widget;
@Input()
widgetConfig: WidgetConfigComponentData;
private settingsDirective: string;
definedDirectiveError: string;
@ -126,6 +130,11 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnInit, On
this.definedSettingsComponent.aliasController = this.aliasController;
}
}
if (propName === 'widgetConfig') {
if (this.definedSettingsComponent) {
this.definedSettingsComponent.widgetConfig = this.widgetConfig;
}
}
}
}
}
@ -214,6 +223,7 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnInit, On
this.definedSettingsComponent.aliasController = this.aliasController;
this.definedSettingsComponent.dashboard = this.dashboard;
this.definedSettingsComponent.widget = this.widget;
this.definedSettingsComponent.widgetConfig = this.widgetConfig;
this.definedSettingsComponent.functionScopeVariables = this.widgetService.getWidgetScopeVariables();
this.changeSubscription = this.definedSettingsComponent.settingsChanged.subscribe((settings) => {
this.updateModel(settings);

20
ui-ngx/src/app/modules/home/components/widget/config/widget-settings.models.ts

@ -276,6 +276,14 @@ export enum BackgroundType {
color = 'color'
}
export const backgroundTypeTranslations = new Map<BackgroundType, string>(
[
[BackgroundType.image, 'widgets.background.background-type-image'],
[BackgroundType.imageUrl, 'widgets.background.background-type-image-url'],
[BackgroundType.color, 'widgets.background.background-type-color']
]
);
export interface OverlaySettings {
enabled: boolean;
color: string;
@ -313,11 +321,13 @@ export const backgroundStyle = (background: BackgroundSettings): ComponentStyle
};
} else {
const imageUrl = background.type === BackgroundType.image ? background.imageBase64 : background.imageUrl;
return {
background: `url(${imageUrl}) no-repeat`,
backgroundSize: 'cover',
backgroundPosition: '50% 50%'
};
if (imageUrl) {
return {
background: `url(${imageUrl}) no-repeat 50% 50% / cover`
};
} else {
return {};
}
}
};

4
ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.scss

@ -71,7 +71,7 @@
background-size: 18px 18px;
}
mat-icon.node-icon {
.mat-icon.node-icon {
width: 22px;
min-width: 22px;
height: 22px;
@ -109,7 +109,7 @@
background-size: 24px 24px;
}
mat-icon.node-icon {
.mat-icon.node-icon {
width: 40px;
min-width: 40px;
height: 40px;

4
ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.scss

@ -64,7 +64,7 @@
background-size: 18px 18px;
}
mat-icon.node-icon {
.mat-icon.node-icon {
width: 22px;
min-width: 22px;
height: 22px;
@ -102,7 +102,7 @@
background-size: 24px 24px;
}
mat-icon.node-icon {
.mat-icon.node-icon {
width: 40px;
min-width: 40px;
height: 40px;

44
ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html

@ -23,12 +23,12 @@
<fieldset *ngFor="let source of sources" [ngClass]="{'fields-group': settings.showGroupTitle}">
<legend class="group-title" *ngIf="settings.showGroupTitle">{{ getGroupTitle(source.datasource) }}
</legend>
<div fxLayout="row" class="layout-wrap"
<div class="tb-multiple-input-layout layout-wrap"
[ngClass]="{'vertical-alignment': isVerticalAlignment || changeAlignment}">
<div *ngFor="let key of visibleKeys(source)"
[ngStyle]="{width: (isVerticalAlignment || changeAlignment) ? '100%' : inputWidthSettings}">
<div class="input-field" *ngIf="key.settings.dataKeyValueType === 'string'">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{key.label}}</mat-label>
<input matInput
formControlName="{{key.formId}}"
@ -50,7 +50,7 @@
</div>
<div class="input-field" *ngIf="key.settings.dataKeyValueType === 'double' ||
key.settings.dataKeyValueType === 'integer'">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{key.label}}</mat-label>
<input matInput
formControlName="{{key.formId}}"
@ -86,7 +86,7 @@
</mat-checkbox>
</div>
<div class="input-field" *ngIf="key.settings.dataKeyValueType === 'JSON'">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{key.label}}</mat-label>
<input
matInput
@ -134,7 +134,7 @@
<div class="input-field mat-block date-time-input" *ngIf="(key.settings.dataKeyValueType === 'dateTime') ||
(key.settings.dataKeyValueType === 'date') ||
(key.settings.dataKeyValueType === 'time')" fxLayout="column">
<mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{key.label}}</mat-label>
<mat-datetimepicker-toggle [for]="datePicker" matPrefix></mat-datetimepicker-toggle>
<mat-datetimepicker #datePicker type="{{datePickerType(key.settings.dataKeyValueType)}}"
@ -155,7 +155,7 @@
</mat-form-field>
</div>
<div class="input-field" *ngIf="key.settings.dataKeyValueType === 'select'">
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{key.label}}</mat-label>
<mat-select formControlName="{{key.formId}}"
[required]="key.settings.required"
@ -172,30 +172,46 @@
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-row space-between color-picker-input" *ngIf="key.settings.dataKeyValueType === 'color'">
<div class="label-container">
<ng-container *ngIf="key.settings.icon || key.settings.safeCustomIcon">
<mat-icon *ngIf="!key.settings.safeCustomIcon; else customToggleIcon">{{key.settings.icon}}</mat-icon>
<ng-template #customToggleIcon>
<img class="mat-icon" [src]="key.settings.safeCustomIcon" alt="icon">
</ng-template>
</ng-container>
{{key.label}}
</div>
<tb-color-input asBoxInput
[required]="key.settings.required"
[requiredText]="getErrorMessageText(key.settings, 'required')"
openOnInput
formControlName="{{key.formId}}">
</tb-color-input>
</div>
</div>
</div>
</fieldset>
</div>
<div class="mat-padding" fxLayout="row" fxLayoutAlign="end center"
<div class="mat-padding tb-multiple-input--buttons-container"
*ngIf="entityDetected && isAllParametersValid && settings.showActionButtons">
<button mat-button color="primary" type="button"
(click)="discardAll()" style="max-height: 50px; margin-right:20px;"
(click)="discardAll()" class="tb-multiple-input--buttons-container__button"
[disabled]="!multipleInputFormGroup.dirty">
{{ resetButtonLabel }}
</button>
<button mat-button mat-raised-button color="primary" type="submit"
style="max-height: 50px; margin-right:20px;"
class="tb-multiple-input--buttons-container__button"
[disabled]="!multipleInputFormGroup.dirty || multipleInputFormGroup.invalid">
{{ saveButtonLabel }}
</button>
</div>
<div class="tb-multiple-input__errors" fxLayout="column" fxLayoutAlign="center center" style="height: 100%;"
*ngIf="(!entityDetected || !isAllParametersValid) && datasourceDetected">
<div style="text-align: center; font-size: 18px; color: #a0a0a0;" [fxHide]="entityDetected">
<div class="tb-multiple-input--errors-container" *ngIf="(!entityDetected || !isAllParametersValid) && datasourceDetected">
<div class="tb-multiple-input--errors-container__error" [fxHide]="entityDetected">
{{ 'widgets.input-widgets.no-entity-selected' | translate }}
</div>
<div style="text-align: center; font-size: 18px; color: #a0a0a0;"
[fxShow]="entityDetected && !isAllParametersValid">
<div class="tb-multiple-input--errors-container__error" [fxShow]="entityDetected && !isAllParametersValid">
{{ 'widgets.input-widgets.not-allowed-entity' | translate }}
</div>
</div>

48
ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.scss

@ -21,7 +21,7 @@
flex-direction: column;
.tb-multiple-input-container {
padding: 0 8px;
padding: 8px 8px 0;
flex: 1 1 100%;
overflow-x: hidden;
overflow-y: auto;
@ -37,6 +37,28 @@
}
}
.tb-multiple-input-layout {
display: flex;
flex-direction: row;
align-items: start;
}
.color-picker-input {
padding: 7px 16px 7px 12px;
margin: 0 10px 22px 0;
border-color: rgba(0, 0, 0, 0.4);
.label-container {
display: flex;
flex-direction: row;
align-items: center;
}
.mat-icon, img {
margin-right: 5px;
}
}
.input-field {
padding-right: 10px;
@ -61,6 +83,30 @@
.vertical-alignment {
flex-direction: column;
}
&--buttons-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: end;
&__button {
max-height: 50px;
margin-right:20px;
}
}
&--errors-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&__error {
text-align: center;
font-size: 18px;
color: #a0a0a0;
}
}
}
}

8
ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts

@ -54,7 +54,7 @@ type FieldAlignment = 'row' | 'column';
type MultipleInputWidgetDataKeyType = 'server' | 'shared' | 'timeseries';
export type MultipleInputWidgetDataKeyValueType = 'string' | 'double' | 'integer' |
'JSON' | 'booleanCheckbox' | 'booleanSwitch' |
'dateTime' | 'date' | 'time' | 'select';
'dateTime' | 'date' | 'time' | 'select' | 'color';
type MultipleInputWidgetDataKeyEditableType = 'editable' | 'disabled' | 'readonly';
type ConvertGetValueFunction = (value: any, ctx: WidgetContext) => any;
@ -390,6 +390,12 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni
}
});
}
} else if (key.settings.dataKeyValueType === 'color') {
formControl.valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.inputChanged(source, key);
});
}
this.multipleInputFormGroup.addControl(key.formId, formControl);
}

2
ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.scss

@ -31,7 +31,7 @@
display: flex;
flex-direction: column;
align-items: center;
mat-icon {
.mat-icon {
margin: auto !important;
}
span.mdc-button__label {

89
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html

@ -0,0 +1,89 @@
<!--
Copyright © 2016-2023 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ng-container [formGroup]="valueCardWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.value-card.value-card-style</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}"
[cols]="horizontal ? 2 : 4"
[colsLtMd]="horizontal ? 1 : 2"
label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueCardLayouts"
[value]="layout"
[image]="valueCardLayoutImageMap.get(layout)">
{{ valueCardLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="showLabel">
{{ 'widgets.value-card.label' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="labelFont"
[previewText]="label">
</tb-font-settings>
<tb-color-settings formControlName="labelColor">
</tb-color-settings>
</div>
</div>
<div [fxShow]="iconEnabled" class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.value-card.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field fxFlex appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
[color]="valueCardWidgetSettingsForm.get('iconColor').value?.color"
formControlName="icon">
</tb-material-icon-select>
<tb-color-settings formControlName="iconColor">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.value-card.value</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="valueColor">
</tb-color-settings>
</div>
</div>
<div [fxShow]="dateEnabled" class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-date-format-select fxFlex formControlName="dateFormat"></tb-date-format-select>
<tb-font-settings formControlName="dateFont"
[previewText]="datePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="dateColor">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
</div>
</ng-container>

200
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts

@ -0,0 +1,200 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Injector } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
valueCardDefaultSettings,
ValueCardLayout, valueCardLayoutImages,
valueCardLayouts, valueCardLayoutTranslations
} from '@home/components/widget/lib/cards/value-card-widget.models';
import { formatValue, isDefinedAndNotNull } from '@core/utils';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import {
DateFormatProcessor,
DateFormatSettings,
getLabel
} from '@home/components/widget/config/widget-settings.models';
@Component({
selector: 'tb-value-card-widget-settings',
templateUrl: './value-card-widget-settings.component.html',
styleUrls: []
})
export class ValueCardWidgetSettingsComponent extends WidgetSettingsComponent {
valueCardLayouts: ValueCardLayout[] = [];
valueCardLayoutTranslationMap = valueCardLayoutTranslations;
valueCardLayoutImageMap = valueCardLayoutImages;
horizontal = false;
valueCardWidgetSettingsForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
datePreviewFn = this._datePreviewFn.bind(this);
get label(): string {
return getLabel(this.widgetConfig.config.datasources);
}
get dateEnabled(): boolean {
const layout: ValueCardLayout = this.valueCardWidgetSettingsForm.get('layout').value;
return ![ValueCardLayout.vertical, ValueCardLayout.simplified].includes(layout);
}
get iconEnabled(): boolean {
const layout: ValueCardLayout = this.valueCardWidgetSettingsForm.get('layout').value;
return layout !== ValueCardLayout.simplified;
}
constructor(protected store: Store<AppState>,
private $injector: Injector,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.valueCardWidgetSettingsForm;
}
protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) {
const params = widgetConfig.typeParameters as any;
this.horizontal = isDefinedAndNotNull(params.horizontal) ? params.horizontal : false;
this.valueCardLayouts = valueCardLayouts(this.horizontal);
}
protected defaultSettings(): WidgetSettings {
return valueCardDefaultSettings(this.horizontal);
}
protected onSettingsSet(settings: WidgetSettings) {
this.valueCardWidgetSettingsForm = this.fb.group({
layout: [settings.layout, []],
showLabel: [settings.showLabel, []],
labelFont: [settings.labelFont, []],
labelColor: [settings.labelColor, []],
showIcon: [settings.showIcon, []],
iconSize: [settings.iconSize, [Validators.min(0)]],
iconSizeUnit: [settings.iconSizeUnit, []],
icon: [settings.icon, []],
iconColor: [settings.iconColor, []],
valueFont: [settings.valueFont, []],
valueColor: [settings.valueColor, []],
showDate: [settings.showDate, []],
dateFormat: [settings.dateFormat, []],
dateFont: [settings.dateFont, []],
dateColor: [settings.dateColor, []],
background: [settings.background, []]
});
}
protected validatorTriggers(): string[] {
return ['layout', 'showLabel', 'showIcon', 'showDate'];
}
protected updateValidators(emitEvent: boolean) {
const layout: ValueCardLayout = this.valueCardWidgetSettingsForm.get('layout').value;
const showLabel: boolean = this.valueCardWidgetSettingsForm.get('showLabel').value;
const showIcon: boolean = this.valueCardWidgetSettingsForm.get('showIcon').value;
const showDate: boolean = this.valueCardWidgetSettingsForm.get('showDate').value;
const dateEnabled = ![ValueCardLayout.vertical, ValueCardLayout.simplified].includes(layout);
const iconEnabled = layout !== ValueCardLayout.simplified;
if (showLabel) {
this.valueCardWidgetSettingsForm.get('labelFont').enable();
this.valueCardWidgetSettingsForm.get('labelColor').enable();
} else {
this.valueCardWidgetSettingsForm.get('labelFont').disable();
this.valueCardWidgetSettingsForm.get('labelColor').disable();
}
if (iconEnabled) {
this.valueCardWidgetSettingsForm.get('showIcon').enable({emitEvent: false});
if (showIcon) {
this.valueCardWidgetSettingsForm.get('iconSize').enable();
this.valueCardWidgetSettingsForm.get('iconSizeUnit').enable();
this.valueCardWidgetSettingsForm.get('icon').enable();
this.valueCardWidgetSettingsForm.get('iconColor').enable();
} else {
this.valueCardWidgetSettingsForm.get('iconSize').disable();
this.valueCardWidgetSettingsForm.get('iconSizeUnit').disable();
this.valueCardWidgetSettingsForm.get('icon').disable();
this.valueCardWidgetSettingsForm.get('iconColor').disable();
}
} else {
this.valueCardWidgetSettingsForm.get('showIcon').disable({emitEvent: false});
this.valueCardWidgetSettingsForm.get('iconSize').disable();
this.valueCardWidgetSettingsForm.get('iconSizeUnit').disable();
this.valueCardWidgetSettingsForm.get('icon').disable();
this.valueCardWidgetSettingsForm.get('iconColor').disable();
}
if (dateEnabled) {
this.valueCardWidgetSettingsForm.get('showDate').enable({emitEvent: false});
if (showDate) {
this.valueCardWidgetSettingsForm.get('dateFormat').enable();
this.valueCardWidgetSettingsForm.get('dateFont').enable();
this.valueCardWidgetSettingsForm.get('dateColor').enable();
} else {
this.valueCardWidgetSettingsForm.get('dateFormat').disable();
this.valueCardWidgetSettingsForm.get('dateFont').disable();
this.valueCardWidgetSettingsForm.get('dateColor').disable();
}
} else {
this.valueCardWidgetSettingsForm.get('showDate').disable({emitEvent: false});
this.valueCardWidgetSettingsForm.get('dateFormat').disable();
this.valueCardWidgetSettingsForm.get('dateFont').disable();
this.valueCardWidgetSettingsForm.get('dateColor').disable();
}
this.valueCardWidgetSettingsForm.get('showIcon').updateValueAndValidity({emitEvent: false});
this.valueCardWidgetSettingsForm.get('showDate').updateValueAndValidity({emitEvent: false});
this.valueCardWidgetSettingsForm.get('labelFont').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('labelColor').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('iconSize').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('iconSizeUnit').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('icon').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('iconColor').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('dateFormat').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('dateFont').updateValueAndValidity({emitEvent});
this.valueCardWidgetSettingsForm.get('dateColor').updateValueAndValidity({emitEvent});
}
private _valuePreviewFn(): string {
const units: string = this.widgetConfig.config.units;
const decimals: number = this.widgetConfig.config.decimals;
return formatValue(22, decimals, units, true);
}
private _datePreviewFn(): string {
const dateFormat: DateFormatSettings = this.valueCardWidgetSettingsForm.get('dateFormat').value;
const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat);
processor.update(Date.now());
return processor.formatted;
}
}

87
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html

@ -0,0 +1,87 @@
<!--
Copyright © 2016-2023 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-background-settings-panel" [formGroup]="backgroundSettingsFormGroup">
<div class="tb-background-settings-title" translate>widgets.background.background-settings</div>
<div class="tb-form-panel">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px">
<div class="tb-form-panel-title" translate>widgets.background.background</div>
<tb-toggle-select formControlName="type" fxFlex>
<tb-toggle-option *ngFor="let type of backgroundTypes"
[value]="type">
{{ backgroundTypeTranslationsMap.get(type) | translate }}
</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-image-input [fxShow]="backgroundSettingsFormGroup.get('type').value === backgroundType.image" formControlName="imageBase64"></tb-image-input>
<div [fxShow]="backgroundSettingsFormGroup.get('type').value === backgroundType.imageUrl" class="tb-form-row space-between">
<div translate>widgets.background.image-url</div>
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="imageUrl" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div [fxShow]="backgroundSettingsFormGroup.get('type').value === backgroundType.color" class="tb-form-row space-between">
<div translate>widgets.color.color</div>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
</div>
</div>
<div class="tb-form-panel" formGroupName="overlay">
<div class="tb-form-panel-title" translate>widgets.background.overlay</div>
<mat-slide-toggle class="mat-slide" formControlName="enabled">
{{ 'widgets.background.enable-overlay' | translate }}
</mat-slide-toggle>
<div class="tb-form-row space-between">
<div translate>widgets.color.color</div>
<tb-color-input asBoxInput
formControlName="color">
</tb-color-input>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.background.blur</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="blur" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix>px</div>
</mat-form-field>
</div>
</div>
<div class="tb-background-settings-preview">
<div class="tb-background-settings-preview-title" translate>
widgets.background.preview
</div>
<div class="tb-background-settings-preview-box mat-elevation-z4" [style]="backgroundStyle">
<div class="tb-background-settings-preview-overlay" [style]="overlayStyle">
</div>
</div>
</div>
<div class="tb-background-settings-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyColorSettings()"
[disabled]="backgroundSettingsFormGroup.invalid || !backgroundSettingsFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

73
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.scss

@ -0,0 +1,73 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../../scss/constants';
.tb-background-settings-panel {
width: 620px;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-lt-md} {
width: 90vw;
}
.tb-background-settings-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-background-settings-preview {
flex: 1;
background: rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
padding: 12px 16px 24px 16px;
align-items: center;
gap: 12px;
}
.tb-background-settings-preview-title {
align-self: stretch;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
color: rgba(0, 0, 0, 0.38);
}
.tb-background-settings-preview-box {
position: relative;
width: 136px;
height: 118px;
border-radius: 2.666px;
}
.tb-background-settings-preview-overlay {
position: absolute;
border-radius: 2.666px;
top: 7.998px;
bottom: 7.998px;
left: 7.998px;
right: 7.998px;
}
.tb-background-settings-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

120
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts

@ -0,0 +1,120 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import {
backgroundStyle,
overlayStyle,
BackgroundSettings,
BackgroundType,
backgroundTypeTranslations, ComponentStyle
} from '@home/components/widget/config/widget-settings.models';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@Component({
selector: 'tb-background-settings-panel',
templateUrl: './background-settings-panel.component.html',
providers: [],
styleUrls: ['./background-settings-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class BackgroundSettingsPanelComponent extends PageComponent implements OnInit {
@Input()
backgroundSettings: BackgroundSettings;
@Input()
popover: TbPopoverComponent<BackgroundSettingsPanelComponent>;
@Output()
backgroundSettingsApplied = new EventEmitter<BackgroundSettings>();
backgroundType = BackgroundType;
backgroundTypes = Object.keys(BackgroundType) as BackgroundType[];
backgroundTypeTranslationsMap = backgroundTypeTranslations;
backgroundSettingsFormGroup: UntypedFormGroup;
backgroundStyle: ComponentStyle = {};
overlayStyle: ComponentStyle = {};
constructor(private fb: UntypedFormBuilder,
protected store: Store<AppState>) {
super(store);
}
ngOnInit(): void {
this.backgroundSettingsFormGroup = this.fb.group(
{
type: [this.backgroundSettings?.type, []],
imageBase64: [this.backgroundSettings?.imageBase64, []],
imageUrl: [this.backgroundSettings?.imageUrl, []],
color: [this.backgroundSettings?.color, []],
overlay: this.fb.group({
enabled: [this.backgroundSettings?.overlay?.enabled, []],
color: [this.backgroundSettings?.overlay?.color, []],
blur: [this.backgroundSettings?.overlay?.blur, []]
})
}
);
this.backgroundSettingsFormGroup.get('type').valueChanges.subscribe(() => {
setTimeout(() => {this.popover?.updatePosition();}, 0);
});
this.backgroundSettingsFormGroup.get('overlay').get('enabled').valueChanges.subscribe(() => {
this.updateValidators();
});
this.backgroundSettingsFormGroup.valueChanges.subscribe(() => {
this.updateBackgroundStyle();
});
this.updateValidators();
this.updateBackgroundStyle();
}
cancel() {
this.popover?.hide();
}
applyColorSettings() {
const backgroundSettings = this.backgroundSettingsFormGroup.value;
this.backgroundSettingsApplied.emit(backgroundSettings);
}
private updateValidators() {
const overlayEnabled: boolean = this.backgroundSettingsFormGroup.get('overlay').get('enabled').value;
if (overlayEnabled) {
this.backgroundSettingsFormGroup.get('overlay').get('color').enable();
this.backgroundSettingsFormGroup.get('overlay').get('blur').enable();
} else {
this.backgroundSettingsFormGroup.get('overlay').get('color').disable();
this.backgroundSettingsFormGroup.get('overlay').get('blur').disable();
}
this.backgroundSettingsFormGroup.get('overlay').get('color').updateValueAndValidity({emitEvent: false});
this.backgroundSettingsFormGroup.get('overlay').get('blur').updateValueAndValidity({emitEvent: false});
}
private updateBackgroundStyle() {
const background: BackgroundSettings = this.backgroundSettingsFormGroup.value;
this.backgroundStyle = backgroundStyle(background);
this.overlayStyle = overlayStyle(background.overlay);
}
}

30
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.html

@ -0,0 +1,30 @@
<!--
Copyright © 2016-2023 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.
-->
<button type="button"
mat-stroked-button
class="tb-box-button tb-background-settings"
[disabled]="disabled"
#matButton
(click)="openBackgroundSettingsPopup($event, matButton)">
<div class="tb-color-preview box" [ngClass]="{'disabled': disabled}">
<div class="tb-color-result" [style]="backgroundStyle">
<div class="tb-color-overlay" [style]="overlayStyle">
</div>
</div>
</div>
</button>

41
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.scss

@ -0,0 +1,41 @@
/**
* Copyright © 2016-2023 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.
*/
button.mat-mdc-button-base.tb-box-button.tb-background-settings {
padding: 0;
.mat-mdc-button-persistent-ripple {
z-index: 2;
}
.tb-color-preview {
width: 38px;
min-width: 38px;
height: 38px;
&.box {
.tb-color-result {
&:after {
border: none;
}
}
.tb-color-overlay {
position: absolute;
border-radius: 3px;
top: 4px;
bottom: 4px;
left: 4px;
right: 4px;
}
}
}
}

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

@ -0,0 +1,120 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, forwardRef, Input, OnInit, Renderer2, ViewContainerRef, ViewEncapsulation } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import {
BackgroundSettings,
backgroundStyle,
BackgroundType,
ComponentStyle,
overlayStyle
} from '@home/components/widget/config/widget-settings.models';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import {
BackgroundSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/background-settings-panel.component';
@Component({
selector: 'tb-background-settings',
templateUrl: './background-settings.component.html',
styleUrls: ['./background-settings.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => BackgroundSettingsComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class BackgroundSettingsComponent implements OnInit, ControlValueAccessor {
@Input()
disabled: boolean;
backgroundType = BackgroundType;
modelValue: BackgroundSettings;
backgroundStyle: ComponentStyle = {};
overlayStyle: ComponentStyle = {};
private propagateChange = null;
constructor(private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef) {}
ngOnInit(): void {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
this.updateBackgroundStyle();
}
writeValue(value: BackgroundSettings): void {
this.modelValue = value;
this.updateBackgroundStyle();
}
openBackgroundSettingsPopup($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
backgroundSettings: this.modelValue
};
const backgroundSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, BackgroundSettingsPanelComponent, 'left', true, null,
ctx,
{},
{}, {}, true);
backgroundSettingsPanelPopover.tbComponentRef.instance.popover = backgroundSettingsPanelPopover;
backgroundSettingsPanelPopover.tbComponentRef.instance.backgroundSettingsApplied.subscribe((backgroundSettings) => {
backgroundSettingsPanelPopover.hide();
this.modelValue = backgroundSettings;
this.updateBackgroundStyle();
this.propagateChange(this.modelValue);
});
}
}
private updateBackgroundStyle() {
if (!this.disabled) {
this.backgroundStyle = backgroundStyle(this.modelValue);
this.overlayStyle = overlayStyle(this.modelValue.overlay);
} else {
this.backgroundStyle = {};
this.overlayStyle = {};
}
}
}

27
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts

@ -21,14 +21,14 @@ import {
Directive,
ElementRef,
forwardRef,
Input,
Input, OnChanges,
OnDestroy, OnInit,
QueryList,
QueryList, SimpleChanges,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms';
import { coerceBoolean } from '@shared/decorators/coercion';
import { Observable, Subject } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
import { map, share, startWith, takeUntil } from 'rxjs/operators';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
@ -73,7 +73,7 @@ export class ImageCardsSelectOptionDirective {
],
encapsulation: ViewEncapsulation.None
})
export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit, AfterContentInit, OnDestroy {
export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit, OnChanges, AfterContentInit, OnDestroy {
@ContentChildren(ImageCardsSelectOptionDirective) imageCardsSelectOptions: QueryList<ImageCardsSelectOptionDirective>;
@ -107,20 +107,33 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
private _destroyed = new Subject<void>();
private _colsChanged = new BehaviorSubject<void>(null);
constructor(private breakpointObserver: BreakpointObserver) {
this.valueFormControl = new UntypedFormControl('');
}
ngOnInit(): void {
const gridColumns = this.breakpointObserver.isMatched(MediaBreakpoints['lt-md']) ? this.colsLtMd : this.cols;
this.cols$ = this.breakpointObserver
.observe(MediaBreakpoints['lt-md']).pipe(
map((state) => state.matches ? this.colsLtMd : this.cols),
this.cols$ = combineLatest({state: this.breakpointObserver
.observe(MediaBreakpoints['lt-md']), colsChanged: this._colsChanged.asObservable()}).pipe(
map((data) => data.state.matches ? this.colsLtMd : this.cols),
startWith(gridColumns),
share()
);
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (['cols', 'colsLtMd'].includes(propName)) {
this._colsChanged.next(null);
}
}
}
}
ngAfterContentInit(): void {
this.imageCardsSelectOptions.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
this.syncImageCardsSelectOptions();

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.html

@ -38,7 +38,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-lt-md">
<div translate>legend.show-values</div>
<mat-chip-listbox multiple formControlName="showValues">
<mat-chip-option value="min">{{ 'legend.min-option' | translate }}</mat-chip-option>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html

@ -69,6 +69,9 @@
<mat-option [value]="'JSON'">
{{ 'widgets.input-widgets.datakey-value-type-json' | translate }}
</mat-option>
<mat-option [value]="'color'">
{{ 'widgets.input-widgets.datakey-value-type-color' | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</section>

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

@ -281,6 +281,13 @@ import { DateFormatSelectComponent } from '@home/components/widget/lib/settings/
import {
DateFormatSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/date-format-settings-panel.component';
import { BackgroundSettingsComponent } from '@home/components/widget/lib/settings/common/background-settings.component';
import {
BackgroundSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/background-settings-panel.component';
import {
ValueCardWidgetSettingsComponent
} from '@home/components/widget/lib/settings/cards/value-card-widget-settings.component';
@NgModule({
declarations: [
@ -391,7 +398,10 @@ import {
ColorSettingsPanelComponent,
CssUnitSelectComponent,
DateFormatSelectComponent,
DateFormatSettingsPanelComponent
DateFormatSettingsPanelComponent,
BackgroundSettingsComponent,
BackgroundSettingsPanelComponent,
ValueCardWidgetSettingsComponent
],
imports: [
CommonModule,
@ -506,7 +516,10 @@ import {
ColorSettingsPanelComponent,
CssUnitSelectComponent,
DateFormatSelectComponent,
DateFormatSettingsPanelComponent
DateFormatSettingsPanelComponent,
BackgroundSettingsComponent,
BackgroundSettingsPanelComponent,
ValueCardWidgetSettingsComponent
]
})
export class WidgetSettingsModule {
@ -575,5 +588,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-route-map-widget-settings': RouteMapWidgetSettingsComponent,
'tb-trip-animation-widget-settings': TripAnimationWidgetSettingsComponent,
'tb-doc-links-widget-settings': DocLinksWidgetSettingsComponent,
'tb-quick-links-widget-settings': QuickLinksWidgetSettingsComponent
'tb-quick-links-widget-settings': QuickLinksWidgetSettingsComponent,
'tb-value-card-widget-settings': ValueCardWidgetSettingsComponent
};

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss

@ -19,6 +19,9 @@
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-xs} {
gap: 8px;
}
.tb-widget-settings {
.fields-group {

2
ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.scss

@ -54,7 +54,7 @@
line-height: 24px;
z-index: 999;
mat-icon {
.mat-icon {
width: 24px;
height: 24px;

3
ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts

@ -546,6 +546,9 @@ export class WidgetComponentService {
if (isUndefined(result.typeParameters.previewHeight)) {
result.typeParameters.previewHeight = '70%';
}
if (isUndefined(result.typeParameters.absoluteHeader)) {
result.typeParameters.absoluteHeader = false;
}
if (isFunction(widgetTypeInstance.actionSources)) {
result.actionSources = widgetTypeInstance.actionSources();
} else {

23
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -20,8 +20,10 @@
<tb-toggle-select *ngIf="!hideToggleHeader && widgetConfigMode === widgetConfigModes.advanced"
appearance="fill" [options]="headerOptions" [(ngModel)]="selectedOption">
</tb-toggle-select>
<ng-content select=".tb-widget-config-header-prefix"></ng-content>
<ng-content select=".tb-widget-config-header-suffix"></ng-content>
<div class="tb-widget-config-header-components">
<ng-content select=".tb-widget-config-header-prefix"></ng-content>
<ng-content select=".tb-widget-config-header-suffix"></ng-content>
</div>
</div>
<div *ngIf="widgetConfigMode === widgetConfigModes.advanced; else basicMode" class="tb-widget-config-content">
<div *ngIf="widgetType !== widgetTypes.static" [fxShow]="selectedOption === 'data'" class="mat-content">
@ -48,18 +50,18 @@
<input matInput formControlName="titleTooltip" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-xs">
<mat-slide-toggle class="mat-slide" formControlName="showTitleIcon">
{{ 'widget-config.display-icon' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="iconSize">
</mat-form-field>
<tb-material-icon-select asBoxInput
[color]="widgetSettings.get('iconColor').value"
formControlName="titleIcon">
</tb-material-icon-select>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="iconSize">
</mat-form-field>
<tb-color-input asBoxInput
formControlName="iconColor">
</tb-color-input>
@ -247,7 +249,7 @@
</div>
<div *ngIf="displayLimits" class="tb-form-panel" [formGroup]="widgetSettings">
<div class="tb-form-panel-title" translate>widget-config.limits</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-xs">
<div translate>widget-config.data-page-size</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="pageSize" type="number" min="1" step="1">
@ -258,19 +260,19 @@
<ng-template #appearance>
<div *ngIf="displayAppearanceDataSettings" class="tb-form-panel" [formGroup]="widgetSettings">
<div class="tb-form-panel-title" translate>widget-config.data-settings</div>
<div *ngIf="displayUnitsConfig" class="tb-form-row space-between">
<div *ngIf="displayUnitsConfig" class="tb-form-row space-between column-xs">
<div translate>widget-config.units</div>
<tb-unit-input
formControlName="units">
</tb-unit-input>
</div>
<div *ngIf="displayUnitsConfig" class="tb-form-row space-between">
<div *ngIf="displayUnitsConfig" class="tb-form-row space-between column-xs">
<div translate>widget-config.decimals</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div *ngIf="displayNoDataDisplayMessageConfig" class="tb-form-row">
<div *ngIf="displayNoDataDisplayMessageConfig" class="tb-form-row column-xs">
<div class="fixed-title-width" translate>widget-config.no-data-display-message</div>
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="noDataDisplayMessage" placeholder="{{ 'widget-config.set-message' | translate }}">
@ -282,6 +284,7 @@
[aliasController]="aliasController"
[dashboard]="dashboard"
[widget]="widget"
[widgetConfig]="modelValue"
formControlName="settings">
</tb-widget-settings>
</div>

35
ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss

@ -20,16 +20,36 @@
.tb-widget-config {
display: flex;
flex-direction: column;
gap: 16px;
gap: 8px;
.tb-widget-config-header {
padding: 24px 24px 8px;
height: 56px;
padding: 24px 24px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-direction: column-reverse;
align-items: flex-start;
@media #{$mat-gt-sm} {
gap: 0;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.tb-widget-config-header-components {
width: 100%;
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.tb-widget-config-content {
& > .mat-content {
padding-top: 8px;
@media #{$mat-xs} {
padding-left: 8px;
padding-right: 8px;
}
}
flex: 1;
overflow: auto;
& > div {
@ -39,6 +59,9 @@
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-xs} {
gap: 8px;
}
}
}
.tb-basic-mode-directive-error {

17
ui-ngx/src/app/modules/home/components/widget/widget-container.component.html

@ -19,7 +19,6 @@
[fullscreenBackgroundStyle]="dashboardStyle"
[fullscreenBackgroundImage]="backgroundImage"
(fullscreenChanged)="onFullscreenChanged($event)"
fxLayout="column"
class="tb-widget"
[ngClass]="{
'tb-highlighted': isHighlighted(widget),
@ -32,8 +31,11 @@
(mousedown)="onMouseDown($event)"
(click)="onClicked($event)"
(contextmenu)="onContextMenu($event)">
<div *ngIf="!!widgetComponent.widgetContext?.inited" fxLayout="row" fxLayoutAlign="space-between start">
<div class="tb-widget-title" fxLayout="column" fxLayoutAlign="center start" *ngIf="widget.showWidgetTitlePanel">
<div *ngIf="!!widgetComponent.widgetContext?.inited"
class="tb-widget-header"
[class]="{'tb-widget-header-absolute': widgetComponent.widgetContext?.absoluteHeader}">
<div *ngIf="widget.showWidgetTitlePanel"
class="tb-widget-title">
<span *ngIf="widget.showTitle"
[ngClass]="{'single-row': widget.hasTimewindow}"
[ngStyle]="widget.titleStyle"
@ -57,9 +59,7 @@
</div>
<div *ngIf="widget.showWidgetActions"
class="tb-widget-actions"
[ngClass]="{'tb-widget-actions-absolute': !(widget.showWidgetTitlePanel&&(widget.showTitle||widget.hasAggregation))}"
fxLayout="row"
fxLayoutAlign="start center"
[class]="{'tb-widget-actions-absolute': !(widget.showWidgetTitlePanel&&(widget.showTitle||widget.hasAggregation))}"
(mousedown)="$event.stopPropagation()">
<button mat-icon-button *ngFor="let action of widget.customHeaderActions"
[fxShow]="!isEdit"
@ -105,9 +105,8 @@
</button>
</div>
</div>
<div fxFlex fxLayout="column" class="tb-widget-content" [ngClass]="{'tb-no-interaction': disableWidgetInteraction}">
<tb-widget fxFlex
#widgetComponent
<div class="tb-widget-content" [ngClass]="{'tb-no-interaction': disableWidgetInteraction}">
<tb-widget #widgetComponent
[dashboardWidget]="widget"
[isEdit]="isEdit"
[isMobile]="isMobile">

47
ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss

@ -14,9 +14,14 @@
* limitations under the License.
*/
tb-widget.tb-widget {
position: relative;
height: 100%;
.tb-widget-container {
position: absolute;
inset: 0;
}
.tb-widget {
position: absolute;
inset: 0;
margin: 0;
overflow: hidden;
outline: none;
@ -25,15 +30,27 @@ tb-widget.tb-widget {
}
div.tb-widget {
position: relative;
height: 100%;
margin: 0;
overflow: hidden;
outline: none;
transition: all .2s ease-in-out;
display: flex;
flex-direction: column;
.tb-widget-header {
display: flex;
flex-direction: row;
place-content: flex-start space-between;
align-items: flex-start;
&-absolute {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 1;
}
}
.tb-widget-title {
display: flex;
flex-direction: column;
place-content: flex-start center;
align-items: flex-start;
max-height: 65px;
padding-top: 5px;
padding-left: 5px;
@ -63,6 +80,10 @@ div.tb-widget {
}
.tb-widget-actions {
display: flex;
flex-direction: row;
place-content: center flex-start;
align-items: center;
z-index: 19;
margin: 5px 0 0;
@ -104,13 +125,11 @@ div.tb-widget {
}
.tb-widget-content {
flex: 1;
position: relative;
&.tb-no-interaction {
pointer-events: none;
}
tb-widget {
position: relative;
width: 100%;
}
}
&.tb-highlighted {

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

@ -409,6 +409,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
elem.classList.add(this.widgetContext.widgetNamespace);
this.widgetType = this.widgetInfo.widgetTypeFunction;
this.typeParameters = this.widgetInfo.typeParameters;
this.widgetContext.absoluteHeader = this.typeParameters.absoluteHeader;
if (!this.widgetType) {
this.widgetTypeInstance = {};

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

@ -76,7 +76,7 @@
</div>
<tb-entity-autocomplete
formControlName="customerId"
labelText="device.wizard.customer-to-assign-device"
labelText="device.assign-to-customer"
[entityType]="entityType.CUSTOMER">
</tb-entity-autocomplete>
<mat-form-field class="mat-block" subscriptSizing="dynamic">

9
ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts

@ -23,7 +23,6 @@ import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';
import { Device, DeviceProfileInfo, DeviceTransportType } from '@shared/models/device.models';
import { MatStepper, StepperOrientation } from '@angular/material/stepper';
import { BaseData, HasId } from '@shared/models/base-data';
import { EntityType } from '@shared/models/entity-type.models';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@ -40,7 +39,7 @@ import { HttpErrorResponse } from '@angular/common/http';
templateUrl: './device-wizard-dialog.component.html',
styleUrls: ['./device-wizard-dialog.component.scss']
})
export class DeviceWizardDialogComponent extends DialogComponent<DeviceWizardDialogComponent, boolean> {
export class DeviceWizardDialogComponent extends DialogComponent<DeviceWizardDialogComponent, Device> {
@ViewChild('addDeviceWizardStepper', {static: true}) addDeviceWizardStepper: MatStepper;
@ -64,7 +63,7 @@ export class DeviceWizardDialogComponent extends DialogComponent<DeviceWizardDia
constructor(protected store: Store<AppState>,
protected router: Router,
public dialogRef: MatDialogRef<DeviceWizardDialogComponent, boolean>,
public dialogRef: MatDialogRef<DeviceWizardDialogComponent, Device>,
private deviceService: DeviceService,
private breakpointObserver: BreakpointObserver,
private fb: FormBuilder) {
@ -121,7 +120,7 @@ export class DeviceWizardDialogComponent extends DialogComponent<DeviceWizardDia
add(): void {
if (this.allValid()) {
this.createDevice().subscribe(
() => this.dialogRef.close(true)
(device) => this.dialogRef.close(device)
);
}
}
@ -137,7 +136,7 @@ export class DeviceWizardDialogComponent extends DialogComponent<DeviceWizardDia
}
}
private createDevice(): Observable<BaseData<HasId>> {
private createDevice(): Observable<Device> {
const device: Device = {
name: this.deviceWizardFormGroup.get('name').value,
label: this.deviceWizardFormGroup.get('label').value,

2
ui-ngx/src/app/modules/home/menu/side-menu.component.scss

@ -49,7 +49,7 @@
&.tb-active {
background-color: rgba(255, 255, 255, .15);
}
mat-icon {
.mat-icon {
margin-right: 8px;
margin-left: 0;
min-width: 1.125rem;

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

Loading…
Cancel
Save