diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 858d816338..be46aebec1 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -267,6 +267,30 @@ "basicModeDirective": "tb-value-card-basic-config", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"labelPosition\":\"top\",\"layout\":\"horizontal\",\"showLabel\":true,\"labelFont\":{\"family\":\"Roboto\",\"size\":16,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\"},\"labelColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"showIcon\":true,\"iconSize\":40,\"iconSizeUnit\":\"px\",\"icon\":\"thermostat\",\"iconColor\":{\"type\":\"constant\",\"color\":\"#5469FF\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"valueFont\":{\"family\":\"Roboto\",\"size\":52,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\"},\"valueColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"showDate\":true,\"dateFormat\":{\"format\":null,\"lastUpdateAgo\":true,\"custom\":false},\"dateFont\":{\"family\":\"Roboto\",\"size\":12,\"sizeUnit\":\"px\",\"style\":\"normal\",\"weight\":\"500\"},\"dateColor\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.38)\",\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}}},\"title\":\"Horizontal value card\",\"dropShadow\":true,\"enableFullscreen\":false,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":\"°C\",\"decimals\":0,\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"configMode\":\"basic\",\"displayTimewindow\":true,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleFont\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":\"1.6\"},\"titleIcon\":\"\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"14px\",\"timewindowStyle\":{\"showIcon\":true,\"iconSize\":\"14px\",\"icon\":\"query_builder\",\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":\"1\"},\"color\":null}}" } + }, + { + "alias": "aggregated_value_card", + "name": "Aggregated value card", + "image": null, + "description": null, + "descriptor": { + "type": "timeseries", + "sizeX": 4.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "\n\n", + "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.aggregatedValueCardWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.aggregatedValueCardWidget.onDataUpdated();\n};\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.aggregatedValueCardWidget.onLatestDataUpdated();\n}\n\nself.onResize = function() {\n self.ctx.$scope.aggregatedValueCardWidget.onResize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.aggregatedValueCardWidget.onEditModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.$scope.aggregatedValueCardWidget.onDestroy();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n previewWidth: '400px',\n previewHeight: '300px',\n embedTitlePanel: true,\n hasAdditionalLatestDataKeys: true\n };\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}", + "latestDataKeySettingsSchema": "{}", + "settingsDirective": "", + "dataKeySettingsDirective": "", + "latestDataKeySettingsDirective": "tb-aggregated-value-card-key-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-aggregated-value-card-basic-config", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"Main building\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"watermeter\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 10 - 5;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 80) {\\n\\tvalue = 80;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]},\"latestDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Avg watermeter\",\"color\":\"#4caf50\",\"settings\":{\"position\":\"center\",\"font\":{\"size\":52,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\",\"lineHeight\":\"1\"},\"color\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"rangeList\":[],\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"showArrow\":false},\"_hash\":0.9408410830697858,\"funcBody\":\"var value = prevValue + Math.random() * 10 - 5;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 80) {\\n\\tvalue = 80;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":\"m³\",\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Delta percent watermeter\",\"color\":\"#f44336\",\"settings\":{\"position\":\"rightTop\",\"font\":{\"size\":14,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\",\"lineHeight\":\"20px\"},\"color\":{\"type\":\"range\",\"color\":\"rgba(0, 0, 0, 0.38)\",\"rangeList\":[{\"from\":null,\"to\":0,\"color\":\"#198038\"},{\"from\":0,\"to\":0,\"color\":\"rgba(0, 0, 0, 0.87)\"},{\"from\":0,\"to\":null,\"color\":\"#D12730\"}],\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"showArrow\":true},\"_hash\":0.06392321853157967,\"funcBody\":\"var value = prevValue + Math.random() * 6 - 3;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -25) {\\n\\tvalue = -25;\\n} else if (value > 25) {\\n\\tvalue = 25;\\n} \\nreturn value;\",\"aggregationType\":null,\"units\":\"%\",\"decimals\":0,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random 2\",\"color\":\"#607d8b\",\"settings\":{\"position\":\"rightBottom\",\"font\":{\"size\":11,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":{\"type\":\"constant\",\"color\":\"rgba(0, 0, 0, 0.38)\",\"rangeList\":[],\"colorFunction\":\"var temperature = value;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\"},\"showArrow\":false},\"_hash\":0.44695098620509865,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":\"m³\",\"decimals\":1,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"hideInterval\":false,\"hideLastInterval\":false,\"hideQuickInterval\":false,\"hideAggregation\":false,\"hideAggInterval\":false,\"hideTimezone\":false,\"selectedTab\":1,\"history\":{\"historyType\":2,\"timewindowMs\":60000,\"interval\":43200000,\"fixedTimewindow\":{\"startTimeMs\":1691927717318,\"endTimeMs\":1692014117318},\"quickInterval\":\"CURRENT_MONTH_SO_FAR\"},\"aggregation\":{\"type\":\"AVG\",\"limit\":25000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":null,\"padding\":\"0\",\"settings\":{\"stack\":false,\"fontSize\":10,\"fontColor\":\"#545454\",\"showTooltip\":true,\"tooltipIndividual\":false,\"tooltipCumulative\":false,\"hideZeros\":false,\"grid\":{\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1,\"color\":\"#545454\",\"backgroundColor\":null,\"tickColor\":\"#DDDDDD\"},\"xaxis\":{\"title\":null,\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"min\":null,\"max\":null,\"title\":null,\"showLabels\":true,\"color\":\"#545454\",\"tickSize\":null,\"tickDecimals\":0,\"ticksFormatter\":\"\"},\"shadowSize\":4,\"smoothLines\":false,\"comparisonEnabled\":false,\"xaxisSecond\":{\"axisPosition\":\"top\",\"title\":null,\"showLabels\":true},\"showLegend\":true,\"legendConfig\":{\"direction\":\"column\",\"position\":\"bottom\",\"sortDataKeys\":false,\"showMin\":false,\"showMax\":false,\"showAvg\":true,\"showTotal\":false,\"showLatest\":false},\"customLegendEnabled\":false},\"title\":\"Aggregated value card\",\"dropShadow\":true,\"enableFullscreen\":false,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":true,\"titleIcon\":\"water_drop\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\",\"lineHeight\":\"24px\"},\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"useDashboardTimewindow\":false,\"displayTimewindow\":true,\"decimals\":0,\"timewindowStyle\":{\"showIcon\":true,\"iconSize\":\"24px\",\"icon\":null,\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":\"rgba(0, 0, 0, 0.38)\",\"displayTypePrefix\":false}}" + } } ] } \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java index 3c8ddb2a7b..de617367dd 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -232,7 +232,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor recipients = notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(), (PlatformUsersNotificationTargetConfig) target.getConfiguration(), new PageLink(recipientsPreviewSize, 0, null, - new SortOrder("createdTime", SortOrder.Direction.DESC))); + SortOrder.BY_CREATED_TIME_DESC)); recipientsCount = (int) recipients.getTotalElements(); recipientsPart = recipients.getData().stream().map(r -> (NotificationRecipient) r).collect(Collectors.toList()); } else { @@ -431,10 +432,23 @@ public class NotificationController extends BaseController { notes = "Returns the list of delivery methods that are properly configured and are allowed to be used for sending notifications." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @GetMapping("/notification/deliveryMethods") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public Set getAvailableDeliveryMethods(@AuthenticationPrincipal SecurityUser user) throws ThingsboardException { - accessControlService.checkPermission(user, Resource.ADMIN_SETTINGS, Operation.READ); return notificationCenter.getAvailableDeliveryMethods(user.getTenantId()); } + + @PostMapping("/notification/settings/user") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public UserNotificationSettings saveUserNotificationSettings(@RequestBody @Valid UserNotificationSettings settings, + @AuthenticationPrincipal SecurityUser user) { + return notificationSettingsService.saveUserNotificationSettings(user.getTenantId(), user.getId(), settings); + } + + @GetMapping("/notification/settings/user") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public UserNotificationSettings getUserNotificationSettings(@AuthenticationPrincipal SecurityUser user) { + return notificationSettingsService.getUserNotificationSettings(user.getTenantId(), user.getId(), true); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 05f56e93bd..2445ee9bb6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -201,7 +201,7 @@ public class TelemetryController extends BaseController { @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, required = true, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES) @PathVariable("scope") String scope) throws ThingsboardException { return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, - (result, tenantId, entityId) -> getAttributeKeysCallback(result, tenantId, entityId, scope)); + (result, tenantId, entityId) -> getAttributeKeysCallback(result, tenantId, entityId, scope)); } @ApiOperation(value = "Get attributes (getAttributes)", @@ -219,9 +219,9 @@ public class TelemetryController extends BaseController { @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @ApiParam(value = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { - SecurityUser user = getCurrentUser(); + SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, - (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, null, keysStr)); + (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, null, keysStr)); } @@ -245,7 +245,7 @@ public class TelemetryController extends BaseController { @ApiParam(value = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, - (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, scope, keysStr)); + (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, scope, keysStr)); } @ApiOperation(value = "Get time-series keys (getTimeseriesKeys)", @@ -259,7 +259,7 @@ public class TelemetryController extends BaseController { @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr) throws ThingsboardException { return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr, - (result, tenantId, entityId) -> Futures.addCallback(tsService.findAllLatest(tenantId, entityId), getTsKeysToResponseCallback(result), MoreExecutors.directExecutor())); + (result, tenantId, entityId) -> Futures.addCallback(tsService.findAllLatest(tenantId, entityId), getTsKeysToResponseCallback(result), MoreExecutors.directExecutor())); } @ApiOperation(value = "Get latest time-series value (getLatestTimeseries)", @@ -462,7 +462,9 @@ public class TelemetryController extends BaseController { notes = "Delete time-series for selected entity based on entity id, entity type and keys." + " Use 'deleteAllDataForKeys' to delete all time-series data." + " Use 'startTs' and 'endTs' to specify time-range instead. " + - " Use 'rewriteLatestIfDeleted' to rewrite latest value (stored in separate table for performance) after deletion of the time range. " + + " Use 'deleteLatest' to delete latest value (stored in separate table for performance) if the value's timestamp matches the time-range. " + + " Use 'rewriteLatestIfDeleted' to rewrite latest value (stored in separate table for performance) if the value's timestamp matches the time-range and 'deleteLatest' param is true." + + " The replacement value will be fetched from the 'time-series' table, and its timestamp will be the most recent one before the defined time-range. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) @ApiResponses(value = { @@ -486,14 +488,16 @@ public class TelemetryController extends BaseController { @RequestParam(name = "startTs", required = false) Long startTs, @ApiParam(value = "A long value representing the end timestamp of removal time range in milliseconds.") @RequestParam(name = "endTs", required = false) Long endTs, + @ApiParam(value = "If the parameter is set to true, the latest telemetry can be removed, otherwise, in case that parameter is set to false the latest value will not removed.") + @RequestParam(name = "deleteLatest", required = false, defaultValue = "true") boolean deleteLatest, @ApiParam(value = "If the parameter is set to true, the latest telemetry will be rewritten in case that current latest value was removed, otherwise, in case that parameter is set to false the new latest value will not set.") @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); - return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted); + return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted, deleteLatest); } private DeferredResult deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys, - Long startTs, Long endTs, boolean rewriteLatestIfDeleted) throws ThingsboardException { + Long startTs, Long endTs, boolean rewriteLatestIfDeleted, boolean deleteLatest) throws ThingsboardException { List keys = toKeysList(keysStr); if (keys.isEmpty()) { return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); @@ -517,7 +521,7 @@ public class TelemetryController extends BaseController { return accessValidator.validateEntityAndCallback(user, Operation.WRITE_TELEMETRY, entityIdStr, (result, tenantId, entityId) -> { List deleteTsKvQueries = new ArrayList<>(); for (String key : keys) { - deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted)); + deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted, deleteLatest)); } tsSubService.deleteTimeseriesAndNotify(tenantId, entityId, keys, deleteTsKvQueries, new FutureCallback<>() { @Override diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index ec06e9e443..d5d73d47e7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -39,12 +39,16 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg; import org.thingsboard.server.gen.edge.v1.ConnectResponseCode; import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg; +import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; @@ -53,6 +57,7 @@ import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.EntityDataProto; +import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; @@ -151,8 +156,8 @@ public final class EdgeGrpcSession implements Closeable { } if (connected) { if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) { - if (requestMsg.hasSyncRequestMsg() && requestMsg.getSyncRequestMsg().getSyncRequired()) { - boolean fullSync = true; + if (requestMsg.hasSyncRequestMsg()) { + boolean fullSync = false; if (requestMsg.getSyncRequestMsg().hasFullSync()) { fullSync = requestMsg.getSyncRequestMsg().getFullSync(); } @@ -658,6 +663,11 @@ public final class EdgeGrpcSession implements Closeable { result.addAll(ctx.getTelemetryProcessor().processTelemetryMsg(edge.getTenantId(), entityData)); } } + if (uplinkMsg.getDeviceProfileUpdateMsgCount() > 0) { + for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : uplinkMsg.getDeviceProfileUpdateMsgList()) { + result.add(ctx.getDeviceProfileProcessor().processDeviceProfileMsgFromEdge(edge.getTenantId(), edge, deviceProfileUpdateMsg)); + } + } if (uplinkMsg.getDeviceUpdateMsgCount() > 0) { for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) { result.add(ctx.getDeviceProcessor().processDeviceMsgFromEdge(edge.getTenantId(), edge, deviceUpdateMsg)); @@ -668,16 +678,36 @@ public final class EdgeGrpcSession implements Closeable { result.add(ctx.getDeviceProcessor().processDeviceCredentialsMsg(edge.getTenantId(), deviceCredentialsUpdateMsg)); } } + if (uplinkMsg.getAssetProfileUpdateMsgCount() > 0) { + for (AssetProfileUpdateMsg assetProfileUpdateMsg : uplinkMsg.getAssetProfileUpdateMsgList()) { + result.add(ctx.getAssetProfileProcessor().processAssetProfileMsgFromEdge(edge.getTenantId(), edge, assetProfileUpdateMsg)); + } + } + if (uplinkMsg.getAssetUpdateMsgCount() > 0) { + for (AssetUpdateMsg assetUpdateMsg : uplinkMsg.getAssetUpdateMsgList()) { + result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg)); + } + } if (uplinkMsg.getAlarmUpdateMsgCount() > 0) { for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) { result.add(ctx.getAlarmProcessor().processAlarmMsg(edge.getTenantId(), alarmUpdateMsg)); } } + if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) { + for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) { + result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg)); + } + } if (uplinkMsg.getRelationUpdateMsgCount() > 0) { for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) { result.add(ctx.getRelationProcessor().processRelationMsg(edge.getTenantId(), relationUpdateMsg)); } } + if (uplinkMsg.getDashboardUpdateMsgCount() > 0) { + for (DashboardUpdateMsg dashboardUpdateMsg : uplinkMsg.getDashboardUpdateMsgList()) { + result.add(ctx.getDashboardProcessor().processDashboardMsgFromEdge(edge.getTenantId(), edge, dashboardUpdateMsg)); + } + } if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) { for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) { result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java index cc05f65f7e..a74a0df18c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java @@ -52,8 +52,6 @@ public class EdgeSyncCursor { fetchers.add(new QueuesEdgeEventFetcher(ctx.getQueueService())); fetchers.add(new RuleChainsEdgeEventFetcher(ctx.getRuleChainService())); fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService(), ctx.getFreemarkerConfig())); - fetchers.add(new DeviceProfilesEdgeEventFetcher(ctx.getDeviceProfileService())); - fetchers.add(new AssetProfilesEdgeEventFetcher(ctx.getAssetProfileService())); fetchers.add(new TenantEdgeEventFetcher(ctx.getTenantService())); fetchers.add(new TenantAdminUsersEdgeEventFetcher(ctx.getUserService())); Customer publicCustomer = ctx.getCustomerService().findOrCreatePublicCustomer(edge.getTenantId()); @@ -63,6 +61,8 @@ public class EdgeSyncCursor { fetchers.add(new CustomerUsersEdgeEventFetcher(ctx.getUserService(), edge.getCustomerId())); } } + fetchers.add(new DeviceProfilesEdgeEventFetcher(ctx.getDeviceProfileService())); + fetchers.add(new AssetProfilesEdgeEventFetcher(ctx.getAssetProfileService())); fetchers.add(new DevicesEdgeEventFetcher(ctx.getDeviceService())); fetchers.add(new AssetsEdgeEventFetcher(ctx.getAssetService())); fetchers.add(new EntityViewsEdgeEventFetcher(ctx.getEntityViewService())); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java index 11679ebe38..239f72dcd6 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -23,9 +23,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; @@ -43,8 +48,11 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo; +import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -109,6 +117,7 @@ import java.util.concurrent.locks.ReentrantLock; public abstract class BaseEdgeProcessor { protected static final Lock deviceCreationLock = new ReentrantLock(); + protected static final Lock assetCreationLock = new ReentrantLock(); protected static final int DEFAULT_PAGE_SIZE = 100; @@ -203,6 +212,21 @@ public abstract class BaseEdgeProcessor { @Autowired protected DataValidator deviceValidator; + @Autowired + protected DataValidator deviceProfileValidator; + + @Autowired + protected DataValidator assetValidator; + + @Autowired + protected DataValidator assetProfileValidator; + + @Autowired + protected DataValidator dashboardValidator; + + @Autowired + protected DataValidator entityViewValidator; + @Autowired protected EdgeMsgConstructor edgeMsgConstructor; @@ -532,4 +556,28 @@ public abstract class BaseEdgeProcessor { return false; } } + + protected void createRelationFromEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId) { + EntityRelation relation = new EntityRelation(); + relation.setFrom(edgeId); + relation.setTo(entityId); + relation.setTypeGroup(RelationTypeGroup.COMMON); + relation.setType(EntityRelation.EDGE_TYPE); + relationService.saveRelation(tenantId, relation); + } + + protected TbMsgMetaData getActionTbMsgMetaData(Edge edge, CustomerId customerId) { + TbMsgMetaData metaData = getTbMsgMetaData(edge); + if (customerId != null && !customerId.isNullUid()) { + metaData.putValue("customerId", customerId.toString()); + } + return metaData; + } + + protected TbMsgMetaData getTbMsgMetaData(Edge edge) { + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("edgeId", edge.getId().toString()); + metaData.putValue("edgeName", edge.getName()); + return metaData; + } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java index d7824a7467..3e61a538c5 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java @@ -15,23 +15,113 @@ */ package org.thingsboard.server.service.edge.rpc.processor.asset; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.dao.asset.BaseAssetService; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; @Component @Slf4j @TbCoreComponent -public class AssetEdgeProcessor extends BaseEdgeProcessor { +public class AssetEdgeProcessor extends BaseAssetProcessor { + + public ListenableFuture processAssetMsgFromEdge(TenantId tenantId, Edge edge, AssetUpdateMsg assetUpdateMsg) { + log.trace("[{}] executing processAssetMsgFromEdge [{}] from edge [{}]", tenantId, assetUpdateMsg, edge.getName()); + AssetId assetId = new AssetId(new UUID(assetUpdateMsg.getIdMSB(), assetUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getSync().set(true); + + switch (assetUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + saveOrUpdateAsset(tenantId, assetId, assetUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + Asset assetToDelete = assetService.findAssetById(tenantId, assetId); + if (assetToDelete != null) { + assetService.unassignAssetFromEdge(tenantId, assetId, edge.getId()); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(assetUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed asset violated {}", tenantId, assetUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getSync().remove(); + } + } + + private void saveOrUpdateAsset(TenantId tenantId, AssetId assetId, AssetUpdateMsg assetUpdateMsg, Edge edge) { + CustomerId customerId = safeGetCustomerId(assetUpdateMsg.getCustomerIdMSB(), assetUpdateMsg.getCustomerIdLSB()); + Pair resultPair = super.saveOrUpdateAsset(tenantId, assetId, assetUpdateMsg, customerId); + Boolean created = resultPair.getFirst(); + if (created) { + createRelationFromEdge(tenantId, edge.getId(), assetId); + pushAssetCreatedEventToRuleEngine(tenantId, edge, assetId); + assetService.assignAssetToEdge(tenantId, assetId, edge.getId()); + } + Boolean assetNameUpdated = resultPair.getSecond(); + if (assetNameUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.ASSET, EdgeEventActionType.UPDATED, assetId, null); + } + } + + private void pushAssetCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AssetId assetId) { + try { + Asset asset = assetService.findAssetById(tenantId, assetId); + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(asset); + TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, assetId, asset.getCustomerId(), + getActionTbMsgMetaData(edge, asset.getCustomerId()), TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, assetId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send ENTITY_CREATED EVENT to rule engine [{}]", asset); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", asset, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push asset action to rule engine: {}", assetId, DataConstants.ENTITY_CREATED, e); + } + } public DownlinkMsg convertAssetEventToDownlink(EdgeEvent edgeEvent) { AssetId assetId = new AssetId(edgeEvent.getEntityId()); @@ -43,7 +133,7 @@ public class AssetEdgeProcessor extends BaseEdgeProcessor { case ASSIGNED_TO_CUSTOMER: case UNASSIGNED_FROM_CUSTOMER: Asset asset = assetService.findAssetById(edgeEvent.getTenantId(), assetId); - if (asset != null) { + if (asset != null && !BaseAssetService.TB_SERVICE_QUEUE.equals(asset.getType())) { UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); AssetUpdateMsg assetUpdateMsg = assetMsgConstructor.constructAssetUpdatedMsg(msgType, asset); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetProfileEdgeProcessor.java index ec0e0b9761..3cae9c5123 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetProfileEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetProfileEdgeProcessor.java @@ -15,22 +15,91 @@ */ package org.thingsboard.server.service.edge.rpc.processor.asset; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; @Component @Slf4j @TbCoreComponent -public class AssetProfileEdgeProcessor extends BaseEdgeProcessor { +public class AssetProfileEdgeProcessor extends BaseAssetProfileProcessor { + + public ListenableFuture processAssetProfileMsgFromEdge(TenantId tenantId, Edge edge, AssetProfileUpdateMsg assetProfileUpdateMsg) { + log.trace("[{}] executing processAssetProfileMsgFromEdge [{}] from edge [{}]", tenantId, assetProfileUpdateMsg, edge.getName()); + AssetProfileId assetProfileId = new AssetProfileId(new UUID(assetProfileUpdateMsg.getIdMSB(), assetProfileUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getSync().set(true); + + switch (assetProfileUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + saveOrUpdateAssetProfile(tenantId, assetProfileId, assetProfileUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(assetProfileUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + log.warn("Failed to process AssetProfileUpdateMsg from Edge [{}]", assetProfileUpdateMsg, e); + return Futures.immediateFailedFuture(e); + } finally { + edgeSynchronizationManager.getSync().remove(); + } + } + + private void saveOrUpdateAssetProfile(TenantId tenantId, AssetProfileId assetProfileId, AssetProfileUpdateMsg assetProfileUpdateMsg, Edge edge) { + boolean created = super.saveOrUpdateAssetProfile(tenantId, assetProfileId, assetProfileUpdateMsg); + if (created) { + createRelationFromEdge(tenantId, edge.getId(), assetProfileId); + pushAssetProfileCreatedEventToRuleEngine(tenantId, edge, assetProfileId); + } + } + + private void pushAssetProfileCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AssetProfileId assetProfileId) { + try { + AssetProfile assetProfile = assetProfileService.findAssetProfileById(tenantId, assetProfileId); + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(assetProfile); + TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, assetProfileId, getTbMsgMetaData(edge), + TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, assetProfileId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send ENTITY_CREATED EVENT to rule engine [{}]", assetProfile); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", assetProfile, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push asset profile action to rule engine: {}", assetProfileId, DataConstants.ENTITY_CREATED, e); + } + } public DownlinkMsg convertAssetProfileEventToDownlink(EdgeEvent edgeEvent) { AssetProfileId assetProfileId = new AssetProfileId(edgeEvent.getEntityId()); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProcessor.java new file mode 100644 index 0000000000..5d728cb4e8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProcessor.java @@ -0,0 +1,77 @@ +/** + * 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.service.edge.rpc.processor.asset; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; + +@Slf4j +public abstract class BaseAssetProcessor extends BaseEdgeProcessor { + + protected Pair saveOrUpdateAsset(TenantId tenantId, AssetId assetId, AssetUpdateMsg assetUpdateMsg, CustomerId customerId) { + boolean created = false; + boolean assetNameUpdated = false; + assetCreationLock.lock(); + try { + Asset asset = assetService.findAssetById(tenantId, assetId); + String assetName = assetUpdateMsg.getName(); + if (asset == null) { + created = true; + asset = new Asset(); + asset.setTenantId(tenantId); + asset.setCreatedTime(Uuids.unixTimestamp(assetId.getId())); + } + Asset assetByName = assetService.findAssetByTenantIdAndName(tenantId, assetName); + if (assetByName != null && !assetByName.getId().equals(assetId)) { + assetName = assetName + "_" + StringUtils.randomAlphanumeric(15); + log.warn("Asset with name {} already exists. Renaming asset name to {}", + assetUpdateMsg.getName(), assetName); + assetNameUpdated = true; + } + asset.setName(assetName); + asset.setType(assetUpdateMsg.getType()); + asset.setLabel(assetUpdateMsg.hasLabel() ? assetUpdateMsg.getLabel() : null); + asset.setAdditionalInfo(assetUpdateMsg.hasAdditionalInfo() + ? JacksonUtil.toJsonNode(assetUpdateMsg.getAdditionalInfo()) : null); + + UUID assetProfileUUID = safeGetUUID(assetUpdateMsg.getAssetProfileIdMSB(), assetUpdateMsg.getAssetProfileIdLSB()); + asset.setAssetProfileId(assetProfileUUID != null ? new AssetProfileId(assetProfileUUID) : null); + + asset.setCustomerId(customerId); + + assetValidator.validate(asset, Asset::getTenantId); + if (created) { + asset.setId(assetId); + } + assetService.saveAsset(asset, false); + } finally { + assetCreationLock.unlock(); + } + return Pair.of(created, assetNameUpdated); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProfileProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProfileProcessor.java new file mode 100644 index 0000000000..4fe778402f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/BaseAssetProfileProcessor.java @@ -0,0 +1,69 @@ +/** + * 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.service.edge.rpc.processor.asset; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Slf4j +public class BaseAssetProfileProcessor extends BaseEdgeProcessor { + + protected boolean saveOrUpdateAssetProfile(TenantId tenantId, AssetProfileId assetProfileId, AssetProfileUpdateMsg assetProfileUpdateMsg) { + boolean created = false; + assetCreationLock.lock(); + try { + AssetProfile assetProfile = assetProfileService.findAssetProfileById(tenantId, assetProfileId); + String assetProfileName = assetProfileUpdateMsg.getName(); + if (assetProfile == null) { + created = true; + assetProfile = new AssetProfile(); + assetProfile.setTenantId(tenantId); + assetProfile.setCreatedTime(Uuids.unixTimestamp(assetProfileId.getId())); + } + assetProfile.setName(assetProfileName); + assetProfile.setDefault(assetProfileUpdateMsg.getDefault()); + assetProfile.setDefaultQueueName(assetProfileUpdateMsg.hasDefaultQueueName() ? assetProfileUpdateMsg.getDefaultQueueName() : null); + assetProfile.setDescription(assetProfileUpdateMsg.hasDescription() ? assetProfileUpdateMsg.getDescription() : null); + assetProfile.setImage(assetProfileUpdateMsg.hasImage() + ? new String(assetProfileUpdateMsg.getImage().toByteArray(), StandardCharsets.UTF_8) : null); + + UUID defaultRuleChainUUID = safeGetUUID(assetProfileUpdateMsg.getDefaultRuleChainIdMSB(), assetProfileUpdateMsg.getDefaultRuleChainIdLSB()); + assetProfile.setDefaultRuleChainId(defaultRuleChainUUID != null ? new RuleChainId(defaultRuleChainUUID) : null); + + UUID defaultDashboardUUID = safeGetUUID(assetProfileUpdateMsg.getDefaultDashboardIdMSB(), assetProfileUpdateMsg.getDefaultDashboardIdLSB()); + assetProfile.setDefaultDashboardId(defaultDashboardUUID != null ? new DashboardId(defaultDashboardUUID) : null); + + assetProfileValidator.validate(assetProfile, AssetProfile::getTenantId); + if (created) { + assetProfile.setId(assetProfileId); + } + assetProfileService.saveAssetProfile(assetProfile, false); + } finally { + assetCreationLock.unlock(); + } + return created; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/BaseDashboardProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/BaseDashboardProcessor.java new file mode 100644 index 0000000000..b055c03cf7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/BaseDashboardProcessor.java @@ -0,0 +1,77 @@ +/** + * 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.service.edge.rpc.processor.dashboard; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.ShortCustomerInfo; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.Set; + +@Slf4j +public abstract class BaseDashboardProcessor extends BaseEdgeProcessor { + + protected boolean saveOrUpdateDashboard(TenantId tenantId, DashboardId dashboardId, DashboardUpdateMsg dashboardUpdateMsg, CustomerId customerId) { + boolean created = false; + Dashboard dashboard = dashboardService.findDashboardById(tenantId, dashboardId); + if (dashboard == null) { + created = true; + dashboard = new Dashboard(); + dashboard.setTenantId(tenantId); + dashboard.setCreatedTime(Uuids.unixTimestamp(dashboardId.getId())); + } + dashboard.setTitle(dashboardUpdateMsg.getTitle()); + dashboard.setConfiguration(JacksonUtil.toJsonNode(dashboardUpdateMsg.getConfiguration())); + Set assignedCustomers = null; + if (dashboardUpdateMsg.hasAssignedCustomers()) { + assignedCustomers = JacksonUtil.fromString(dashboardUpdateMsg.getAssignedCustomers(), new TypeReference<>() { + }); + dashboard.setAssignedCustomers(assignedCustomers); + } + + dashboardValidator.validate(dashboard, Dashboard::getTenantId); + if (created) { + dashboard.setId(dashboardId); + } + Dashboard savedDashboard = dashboardService.saveDashboard(dashboard, false); + if (assignedCustomers != null && !assignedCustomers.isEmpty()) { + for (ShortCustomerInfo assignedCustomer : assignedCustomers) { + if (assignedCustomer.getCustomerId().equals(customerId)) { + dashboardService.assignDashboardToCustomer(tenantId, dashboardId, assignedCustomer.getCustomerId()); + } + } + } else { + unassignCustomersFromDashboard(tenantId, savedDashboard); + } + return created; + } + + private void unassignCustomersFromDashboard(TenantId tenantId, Dashboard dashboard) { + if (dashboard.getAssignedCustomers() != null && !dashboard.getAssignedCustomers().isEmpty()) { + for (ShortCustomerInfo assignedCustomer : dashboard.getAssignedCustomers()) { + dashboardService.unassignDashboardFromCustomer(tenantId, dashboard.getId(), assignedCustomer.getCustomerId()); + } + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java index 14f566db0a..59f12ec64b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java @@ -15,22 +15,103 @@ */ package org.thingsboard.server.service.edge.rpc.processor.dashboard; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; @Component @Slf4j @TbCoreComponent -public class DashboardEdgeProcessor extends BaseEdgeProcessor { +public class DashboardEdgeProcessor extends BaseDashboardProcessor { + + public ListenableFuture processDashboardMsgFromEdge(TenantId tenantId, Edge edge, DashboardUpdateMsg dashboardUpdateMsg) { + log.trace("[{}] executing processDashboardMsgFromEdge [{}] from edge [{}]", tenantId, dashboardUpdateMsg, edge.getName()); + DashboardId dashboardId = new DashboardId(new UUID(dashboardUpdateMsg.getIdMSB(), dashboardUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getSync().set(true); + + switch (dashboardUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + saveOrUpdateDashboard(tenantId, dashboardId, dashboardUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + Dashboard dashboardToDelete = dashboardService.findDashboardById(tenantId, dashboardId); + if (dashboardToDelete != null) { + dashboardService.unassignDashboardFromEdge(tenantId, dashboardId, edge.getId()); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(dashboardUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed dashboard violated {}", tenantId, dashboardUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getSync().remove(); + } + } + + private void saveOrUpdateDashboard(TenantId tenantId, DashboardId dashboardId, DashboardUpdateMsg dashboardUpdateMsg, Edge edge) { + CustomerId customerId = safeGetCustomerId(dashboardUpdateMsg.getCustomerIdMSB(), dashboardUpdateMsg.getCustomerIdLSB()); + boolean created = super.saveOrUpdateDashboard(tenantId, dashboardId, dashboardUpdateMsg, customerId); + if (created) { + createRelationFromEdge(tenantId, edge.getId(), dashboardId); + pushDashboardCreatedEventToRuleEngine(tenantId, edge, dashboardId); + dashboardService.assignDashboardToEdge(tenantId, dashboardId, edge.getId()); + } + } + + private void pushDashboardCreatedEventToRuleEngine(TenantId tenantId, Edge edge, DashboardId dashboardId) { + try { + Dashboard dashboard = dashboardService.findDashboardById(tenantId, dashboardId); + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(dashboard); + TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, dashboardId, null, + getActionTbMsgMetaData(edge, null), TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, dashboardId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send ENTITY_CREATED EVENT to rule engine [{}]", dashboard); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", dashboard, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push dashboard action to rule engine: {}", dashboardId, DataConstants.ENTITY_CREATED, e); + } + } public DownlinkMsg convertDashboardEventToDownlink(EdgeEvent edgeEvent) { DashboardId dashboardId = new DashboardId(edgeEvent.getEntityId()); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java index 1421cf32c0..580f752ac1 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProcessor.java @@ -57,13 +57,13 @@ public abstract class BaseDeviceProcessor extends BaseEdgeProcessor { device = new Device(); device.setTenantId(tenantId); device.setCreatedTime(Uuids.unixTimestamp(deviceId.getId())); - Device deviceByName = deviceService.findDeviceByTenantIdAndName(tenantId, deviceName); - if (deviceByName != null) { - deviceName = deviceName + "_" + StringUtils.randomAlphabetic(15); - log.warn("Device with name {} already exists. Renaming device name to {}", - deviceUpdateMsg.getName(), deviceName); - deviceNameUpdated = true; - } + } + Device deviceByName = deviceService.findDeviceByTenantIdAndName(tenantId, deviceName); + if (deviceByName != null && !deviceByName.getId().equals(deviceId)) { + deviceName = deviceName + "_" + StringUtils.randomAlphabetic(15); + log.warn("Device with name {} already exists. Renaming device name to {}", + deviceUpdateMsg.getName(), deviceName); + deviceNameUpdated = true; } device.setName(deviceName); device.setType(deviceUpdateMsg.getType()); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProfileProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProfileProcessor.java new file mode 100644 index 0000000000..662a497528 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/BaseDeviceProfileProcessor.java @@ -0,0 +1,102 @@ +/** + * 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.service.edge.rpc.processor.device; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +public class BaseDeviceProfileProcessor extends BaseEdgeProcessor { + + @Autowired + private DataDecodingEncodingService dataDecodingEncodingService; + + protected boolean saveOrUpdateDeviceProfile(TenantId tenantId, DeviceProfileId deviceProfileId, DeviceProfileUpdateMsg deviceProfileUpdateMsg) { + boolean created = false; + deviceCreationLock.lock(); + try { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(tenantId, deviceProfileId); + if (deviceProfile == null) { + created = true; + deviceProfile = new DeviceProfile(); + deviceProfile.setTenantId(tenantId); + deviceProfile.setCreatedTime(Uuids.unixTimestamp(deviceProfileId.getId())); + } + deviceProfile.setName(deviceProfileUpdateMsg.getName()); + deviceProfile.setDescription(deviceProfileUpdateMsg.hasDescription() ? deviceProfileUpdateMsg.getDescription() : null); + deviceProfile.setDefault(deviceProfileUpdateMsg.getDefault()); + deviceProfile.setType(DeviceProfileType.valueOf(deviceProfileUpdateMsg.getType())); + deviceProfile.setTransportType(deviceProfileUpdateMsg.hasTransportType() + ? DeviceTransportType.valueOf(deviceProfileUpdateMsg.getTransportType()) : DeviceTransportType.DEFAULT); + deviceProfile.setImage(deviceProfileUpdateMsg.hasImage() + ? new String(deviceProfileUpdateMsg.getImage().toByteArray(), StandardCharsets.UTF_8) : null); + deviceProfile.setProvisionType(deviceProfileUpdateMsg.hasProvisionType() + ? DeviceProfileProvisionType.valueOf(deviceProfileUpdateMsg.getProvisionType()) : DeviceProfileProvisionType.DISABLED); + deviceProfile.setProvisionDeviceKey(deviceProfileUpdateMsg.hasProvisionDeviceKey() + ? deviceProfileUpdateMsg.getProvisionDeviceKey() : null); + deviceProfile.setDefaultQueueName(deviceProfileUpdateMsg.getDefaultQueueName()); + + Optional profileDataOpt = + dataDecodingEncodingService.decode(deviceProfileUpdateMsg.getProfileDataBytes().toByteArray()); + deviceProfile.setProfileData(profileDataOpt.orElse(null)); + + UUID defaultRuleChainUUID = safeGetUUID(deviceProfileUpdateMsg.getDefaultRuleChainIdMSB(), deviceProfileUpdateMsg.getDefaultRuleChainIdLSB()); + deviceProfile.setDefaultRuleChainId(defaultRuleChainUUID != null ? new RuleChainId(defaultRuleChainUUID) : null); + + UUID defaultDashboardUUID = safeGetUUID(deviceProfileUpdateMsg.getDefaultDashboardIdMSB(), deviceProfileUpdateMsg.getDefaultDashboardIdLSB()); + deviceProfile.setDefaultDashboardId(defaultDashboardUUID != null ? new DashboardId(defaultDashboardUUID) : null); + + String defaultQueueName = StringUtils.isNotBlank(deviceProfileUpdateMsg.getDefaultQueueName()) + ? deviceProfileUpdateMsg.getDefaultQueueName() : null; + deviceProfile.setDefaultQueueName(defaultQueueName); + + UUID firmwareUUID = safeGetUUID(deviceProfileUpdateMsg.getFirmwareIdMSB(), deviceProfileUpdateMsg.getFirmwareIdLSB()); + deviceProfile.setFirmwareId(firmwareUUID != null ? new OtaPackageId(firmwareUUID) : null); + + UUID softwareUUID = safeGetUUID(deviceProfileUpdateMsg.getSoftwareIdMSB(), deviceProfileUpdateMsg.getSoftwareIdLSB()); + deviceProfile.setSoftwareId(softwareUUID != null ? new OtaPackageId(softwareUUID) : null); + + + deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId); + if (created) { + deviceProfile.setId(deviceProfileId); + } + deviceProfileService.saveDeviceProfile(deviceProfile, false); + } finally { + deviceCreationLock.unlock(); + } + return created; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java index d083c5b16f..dfa5705840 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java @@ -35,12 +35,8 @@ import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.msg.TbMsg; @@ -114,15 +110,6 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor { } } - private void createRelationFromEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId) { - EntityRelation relation = new EntityRelation(); - relation.setFrom(edgeId); - relation.setTo(entityId); - relation.setTypeGroup(RelationTypeGroup.COMMON); - relation.setType(EntityRelation.EDGE_TYPE); - relationService.saveRelation(tenantId, relation); - } - private void pushDeviceCreatedEventToRuleEngine(TenantId tenantId, Edge edge, DeviceId deviceId) { try { Device device = deviceService.findDeviceById(tenantId, deviceId); @@ -137,7 +124,7 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor { @Override public void onFailure(Throwable t) { - log.debug("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", device, t); + log.warn("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", device, t); } }); } catch (JsonProcessingException | IllegalArgumentException e) { @@ -145,21 +132,6 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor { } } - private TbMsgMetaData getActionTbMsgMetaData(Edge edge, CustomerId customerId) { - TbMsgMetaData metaData = getTbMsgMetaData(edge); - if (customerId != null && !customerId.isNullUid()) { - metaData.putValue("customerId", customerId.toString()); - } - return metaData; - } - - private TbMsgMetaData getTbMsgMetaData(Edge edge) { - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("edgeId", edge.getId().toString()); - metaData.putValue("edgeName", edge.getName()); - return metaData; - } - public ListenableFuture processDeviceRpcCallFromEdge(TenantId tenantId, Edge edge, DeviceRpcCallMsg deviceRpcCallMsg) { log.trace("[{}] processDeviceRpcCallFromEdge [{}]", tenantId, deviceRpcCallMsg); if (deviceRpcCallMsg.hasResponseMsg()) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProfileEdgeProcessor.java index c888ec2925..edaffd6f74 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProfileEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceProfileEdgeProcessor.java @@ -15,22 +15,92 @@ */ package org.thingsboard.server.service.edge.rpc.processor.device; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; @Component @Slf4j @TbCoreComponent -public class DeviceProfileEdgeProcessor extends BaseEdgeProcessor { +public class DeviceProfileEdgeProcessor extends BaseDeviceProfileProcessor { + + + public ListenableFuture processDeviceProfileMsgFromEdge(TenantId tenantId, Edge edge, DeviceProfileUpdateMsg deviceProfileUpdateMsg) { + log.trace("[{}] executing processDeviceProfileMsgFromEdge [{}] from edge [{}]", tenantId, deviceProfileUpdateMsg, edge.getName()); + DeviceProfileId deviceProfileId = new DeviceProfileId(new UUID(deviceProfileUpdateMsg.getIdMSB(), deviceProfileUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getSync().set(true); + + switch (deviceProfileUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + saveOrUpdateDeviceProfile(tenantId, deviceProfileId, deviceProfileUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(deviceProfileUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + log.warn("Failed to process DeviceProfileUpdateMsg from Edge [{}]", deviceProfileUpdateMsg, e); + return Futures.immediateFailedFuture(e); + } finally { + edgeSynchronizationManager.getSync().remove(); + } + } + + private void saveOrUpdateDeviceProfile(TenantId tenantId, DeviceProfileId deviceProfileId, DeviceProfileUpdateMsg deviceProfileUpdateMsg, Edge edge) { + boolean created = super.saveOrUpdateDeviceProfile(tenantId, deviceProfileId, deviceProfileUpdateMsg); + if (created) { + createRelationFromEdge(tenantId, edge.getId(), deviceProfileId); + pushDeviceProfileCreatedEventToRuleEngine(tenantId, edge, deviceProfileId); + } + } + + private void pushDeviceProfileCreatedEventToRuleEngine(TenantId tenantId, Edge edge, DeviceProfileId deviceProfileId) { + try { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(tenantId, deviceProfileId); + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(deviceProfile); + TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceProfileId, getTbMsgMetaData(edge), + TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, deviceProfileId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send ENTITY_CREATED EVENT to rule engine [{}]", deviceProfile); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", deviceProfile, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push device profile action to rule engine: {}", deviceProfileId, DataConstants.ENTITY_CREATED, e); + } + } public DownlinkMsg convertDeviceProfileEventToDownlink(EdgeEvent edgeEvent) { DeviceProfileId deviceProfileId = new DeviceProfileId(edgeEvent.getEntityId()); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/BaseEntityViewProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/BaseEntityViewProcessor.java new file mode 100644 index 0000000000..509b2e5196 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/BaseEntityViewProcessor.java @@ -0,0 +1,76 @@ +/** + * 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.service.edge.rpc.processor.entityview; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.EdgeEntityType; +import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; + +@Slf4j +public abstract class BaseEntityViewProcessor extends BaseEdgeProcessor { + + protected Pair saveOrUpdateEntityView(TenantId tenantId, EntityViewId entityViewId, EntityViewUpdateMsg entityViewUpdateMsg, CustomerId customerId) { + boolean created = false; + boolean entityViewNameUpdated = false; + EntityView entityView = entityViewService.findEntityViewById(tenantId, entityViewId); + String entityViewName = entityViewUpdateMsg.getName(); + if (entityView == null) { + created = true; + entityView = new EntityView(); + entityView.setTenantId(tenantId); + entityView.setCreatedTime(Uuids.unixTimestamp(entityViewId.getId())); + } + EntityView entityViewByName = entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName); + if (entityViewByName != null && !entityViewByName.getId().equals(entityViewId)) { + entityViewName = entityViewName + "_" + StringUtils.randomAlphanumeric(15); + log.warn("Entity view with name {} already exists. Renaming entity view name to {}", + entityViewUpdateMsg.getName(), entityViewName); + entityViewNameUpdated = true; + } + entityView.setName(entityViewName); + entityView.setType(entityViewUpdateMsg.getType()); + entityView.setCustomerId(customerId); + entityView.setAdditionalInfo(entityViewUpdateMsg.hasAdditionalInfo() ? + JacksonUtil.toJsonNode(entityViewUpdateMsg.getAdditionalInfo()) : null); + + UUID entityIdUUID = safeGetUUID(entityViewUpdateMsg.getEntityIdMSB(), entityViewUpdateMsg.getEntityIdLSB()); + if (EdgeEntityType.DEVICE.equals(entityViewUpdateMsg.getEntityType())) { + entityView.setEntityId(entityIdUUID != null ? new DeviceId(entityIdUUID) : null); + } else if (EdgeEntityType.ASSET.equals(entityViewUpdateMsg.getEntityType())) { + entityView.setEntityId(entityIdUUID != null ? new AssetId(entityIdUUID) : null); + } + + entityViewValidator.validate(entityView, EntityView::getTenantId); + if (created) { + entityView.setId(entityViewId); + } + entityViewService.saveEntityView(entityView, false); + return Pair.of(created, entityViewNameUpdated); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java index 0964a434ba..e78b6b4ff5 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java @@ -15,22 +15,111 @@ */ package org.thingsboard.server.service.edge.rpc.processor.entityview; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; @Component @Slf4j @TbCoreComponent -public class EntityViewEdgeProcessor extends BaseEdgeProcessor { +public class EntityViewEdgeProcessor extends BaseEntityViewProcessor { + + public ListenableFuture processEntityViewMsgFromEdge(TenantId tenantId, Edge edge, EntityViewUpdateMsg entityViewUpdateMsg) { + log.trace("[{}] executing processEntityViewMsgFromEdge [{}] from edge [{}]", tenantId, entityViewUpdateMsg, edge.getName()); + EntityViewId entityViewId = new EntityViewId(new UUID(entityViewUpdateMsg.getIdMSB(), entityViewUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getSync().set(true); + + switch (entityViewUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + saveOrUpdateEntityView(tenantId, entityViewId, entityViewUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + EntityView entityViewToDelete = entityViewService.findEntityViewById(tenantId, entityViewId); + if (entityViewToDelete != null) { + entityViewService.unassignEntityViewFromEdge(tenantId, entityViewId, edge.getId()); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(entityViewUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed entity views violated {}", tenantId, entityViewUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getSync().remove(); + } + } + + private void saveOrUpdateEntityView(TenantId tenantId, EntityViewId entityViewId, EntityViewUpdateMsg entityViewUpdateMsg, Edge edge) { + CustomerId customerId = safeGetCustomerId(entityViewUpdateMsg.getCustomerIdMSB(), entityViewUpdateMsg.getCustomerIdLSB()); + Pair resultPair = super.saveOrUpdateEntityView(tenantId, entityViewId, entityViewUpdateMsg, customerId); + Boolean created = resultPair.getFirst(); + if (created) { + createRelationFromEdge(tenantId, edge.getId(), entityViewId); + pushAssetCreatedEventToRuleEngine(tenantId, edge, entityViewId); + entityViewService.assignEntityViewToEdge(tenantId, entityViewId, edge.getId()); + } + Boolean assetNameUpdated = resultPair.getSecond(); + if (assetNameUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.ENTITY_VIEW, EdgeEventActionType.UPDATED, entityViewId, null); + } + } + + private void pushAssetCreatedEventToRuleEngine(TenantId tenantId, Edge edge, EntityViewId entityViewId) { + try { + EntityView entityView = entityViewService.findEntityViewById(tenantId, entityViewId); + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(entityView); + TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, entityViewId, entityView.getCustomerId(), + getActionTbMsgMetaData(edge, entityView.getCustomerId()), TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, entityViewId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send ENTITY_CREATED EVENT to rule engine [{}]", entityView); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", entityView, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push entity view action to rule engine: {}", entityViewId, DataConstants.ENTITY_CREATED, e); + } + } public DownlinkMsg convertEntityViewEventToDownlink(EdgeEvent edgeEvent) { EntityViewId entityViewId = new EntityViewId(edgeEvent.getEntityId()); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java index e776c040c0..2dc246d9a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java @@ -34,7 +34,6 @@ import org.thingsboard.server.common.data.alarm.AlarmUpdateRequest; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.TimePageLink; diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 34515f827d..2e2b3e96be 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -756,6 +756,10 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService conn.createStatement().execute("CREATE INDEX IF NOT EXISTS idx_rule_node_type_configuration_version ON rule_node(type, configuration_version);"); } catch (Exception e) { } + try { + conn.createStatement().execute("CREATE INDEX IF NOT EXISTS idx_notification_recipient_id_unread ON notification(recipient_id) WHERE status <> 'READ';"); + } catch (Exception e) { + } conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3005002;"); } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java index cb77cc0948..042d5cbf00 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.notification.NotificationRequestStatus import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; +import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; import org.thingsboard.server.common.data.notification.targets.NotificationRecipient; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; @@ -237,7 +238,17 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple private void processForRecipient(NotificationDeliveryMethod deliveryMethod, NotificationRecipient recipient, NotificationProcessingContext ctx) throws Exception { if (ctx.getStats().contains(deliveryMethod, recipient.getId())) { throw new AlreadySentException(); + } else { + ctx.getStats().reportProcessed(deliveryMethod, recipient.getId()); } + + if (recipient instanceof User) { + UserNotificationSettings settings = notificationSettingsService.getUserNotificationSettings(ctx.getTenantId(), ((User) recipient).getId(), false); + if (!settings.isEnabled(ctx.getNotificationType(), deliveryMethod)) { + throw new RuntimeException("User disabled " + deliveryMethod.getName() + " notifications of this type"); + } + } + NotificationChannel notificationChannel = channels.get(deliveryMethod); DeliveryMethodNotificationTemplate processedTemplate = ctx.getProcessedTemplate(deliveryMethod, recipient); @@ -251,7 +262,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple Notification notification = Notification.builder() .requestId(request.getId()) .recipientId(recipient.getId()) - .type(ctx.getNotificationTemplate().getNotificationType()) + .type(ctx.getNotificationType()) .subject(processedTemplate.getSubject()) .text(processedTemplate.getBody()) .additionalConfig(processedTemplate.getAdditionalConfig()) diff --git a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java index 27a9cabe43..c4d8895266 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationRequestStats; +import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.settings.NotificationDeliveryMethodConfig; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.targets.NotificationRecipient; @@ -52,6 +53,8 @@ public class NotificationProcessingContext { private final Set deliveryMethods; @Getter private final NotificationTemplate notificationTemplate; + @Getter + private final NotificationType notificationType; private final Map templates; @Getter @@ -65,6 +68,7 @@ public class NotificationProcessingContext { this.deliveryMethods = deliveryMethods; this.settings = settings; this.notificationTemplate = template; + this.notificationType = template.getNotificationType(); this.templates = new EnumMap<>(NotificationDeliveryMethod.class); this.stats = new NotificationRequestStats(); init(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index 51f4d5283f..ac7d796e33 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -363,7 +363,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService< callback.onSuccess(); } } catch (Exception e) { - callback.onFailure(new RuleEngineException(e.getMessage())); + callback.onFailure(new RuleEngineException(e.getMessage(), e)); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java index ef2ba8798d..c2171c2550 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java @@ -17,11 +17,14 @@ package org.thingsboard.server.service.queue; import io.micrometer.core.instrument.Timer; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.ExceptionUtil; +import org.thingsboard.server.common.data.exception.AbstractRateLimitException; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.RuleEngineException; import org.thingsboard.server.common.msg.queue.RuleNodeInfo; import org.thingsboard.server.common.msg.queue.TbMsgCallback; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -57,8 +60,23 @@ public class TbMsgPackCallback implements TbMsgCallback { ctx.onSuccess(id); } + @Override + public void onRateLimit(RuleEngineException e) { + log.debug("[{}] ON RATE LIMIT", id, e); + //TODO notify tenant on rate limit + if (failedMsgTimer != null) { + failedMsgTimer.record(System.currentTimeMillis() - startMsgProcessing, TimeUnit.MILLISECONDS); + } + ctx.onSuccess(id); + } + @Override public void onFailure(RuleEngineException e) { + if (ExceptionUtil.lookupExceptionInCause(e, AbstractRateLimitException.class) != null) { + onRateLimit(e); + return; + } + log.trace("[{}] ON FAILURE", id, e); if (failedMsgTimer != null) { failedMsgTimer.record(System.currentTimeMillis() - startMsgProcessing, TimeUnit.MILLISECONDS); diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index 8b09565ef9..cdc5afe361 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -228,7 +228,6 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService= deviceState.getLastActivityTime()) { + deviceState.setLastInactivityAlarmTime(0L); + save(deviceId, INACTIVITY_ALARM_TIME, 0L); + } } } } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index c8742921b0..e874e41cd7 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -96,7 +96,10 @@ 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}" + # The recalculate_delay property recommended in a microservices architecture setup for rule-engine services. + # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. + # The delay is recommended because the initialization of rule chain actors is time-consuming. Avoiding unnecessary recalculations during a restart can enhance system performance and stability. + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" cluster: stats: diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 58367c9531..c32b4cf8d7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -796,6 +796,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return readResponse(doDelete(urlTemplate, params).andExpect(status().isOk()), responseClass); } + protected T doDeleteAsync(String urlTemplate, Class responseClass, String... params) throws Exception { + return readResponse(doDeleteAsync(urlTemplate, DEFAULT_TIMEOUT, params).andExpect(status().isOk()), responseClass); + } + protected ResultActions doPost(String urlTemplate, String... params) throws Exception { MockHttpServletRequestBuilder postRequest = post(urlTemplate); setJwtToken(postRequest); @@ -828,6 +832,15 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(deleteRequest); } + protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception { + MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params); + setJwtToken(deleteRequest); +// populateParams(deleteRequest, params); + MvcResult result = mockMvc.perform(deleteRequest).andReturn(); + result.getAsyncResult(timeout); + return mockMvc.perform(asyncDispatch(result)); + } + protected void populateParams(MockHttpServletRequestBuilder request, String... params) { if (params != null && params.length > 0) { Assert.assertEquals(0, params.length % 2); diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java index fc6fc33b8f..f7e5f6f6f3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -15,15 +15,22 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Assert; import org.junit.Test; import org.springframework.test.context.TestPropertySource; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.List; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.query.EntityKeyType.TIME_SERIES; @DaoSqlTest @TestPropertySource(properties = { @@ -44,6 +51,92 @@ public class TelemetryControllerTest extends AbstractControllerTest { doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody, String.class, status().isBadRequest()); } + @Test + public void testDeleteAllTelemetryWithLatest() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + + SingleEntityFilter filter = new SingleEntityFilter(); + filter.setSingleEntity(device.getId()); + + getWsClient().subscribeLatestUpdate(List.of(new EntityKey(TIME_SERIES, "data")), filter); + + getWsClient().registerWaitForUpdate(1); + + long startTs = System.currentTimeMillis(); + + String testBody = "{\"data\": \"value\"}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk()); + + long endTs = System.currentTimeMillis(); + + ObjectNode latest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data", ObjectNode.class); + + Assert.assertNotNull(latest); + var data = latest.get("data"); + Assert.assertNotNull(data); + + Assert.assertEquals("value", data.get(0).get("value").asText()); + + ObjectNode timeseries = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data&startTs={startTs}&endTs={endTs}", ObjectNode.class, startTs, endTs); + + Assert.assertNotNull(timeseries); + + Assert.assertEquals("value", timeseries.get("data").get(0).get("value").asText()); + + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete?keys=data&deleteAllDataForKeys=true", String.class); + + latest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data", ObjectNode.class); + + Assert.assertTrue(latest.get("data").get(0).get("value").isNull()); + + timeseries = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data&startTs={startTs}&endTs={endTs}", ObjectNode.class, startTs, endTs); + + Assert.assertTrue(timeseries.isEmpty()); + } + + @Test + public void testDeleteAllTelemetryWithoutLatest() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + + SingleEntityFilter filter = new SingleEntityFilter(); + filter.setSingleEntity(device.getId()); + + getWsClient().subscribeLatestUpdate(List.of(new EntityKey(TIME_SERIES, "data")), filter); + + getWsClient().registerWaitForUpdate(1); + + long startTs = System.currentTimeMillis(); + + String testBody = "{\"data\": \"value\"}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk()); + + long endTs = System.currentTimeMillis(); + + ObjectNode latest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data", ObjectNode.class); + + Assert.assertNotNull(latest); + + Assert.assertEquals("value", latest.get("data").get(0).get("value").asText()); + + ObjectNode timeseries = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data&startTs={startTs}&endTs={endTs}", ObjectNode.class, startTs, endTs); + + Assert.assertNotNull(timeseries); + + Assert.assertEquals("value", timeseries.get("data").get(0).get("value").asText()); + + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete?keys=data&deleteAllDataForKeys=true&deleteLatest=false", String.class); + + latest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data", ObjectNode.class); + + Assert.assertEquals("value", latest.get("data").get(0).get("value").asText()); + + timeseries = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=data&startTs={startTs}&endTs={endTs}", ObjectNode.class, startTs, endTs); + + Assert.assertTrue(timeseries.isEmpty()); + } + @Test public void testValueConstraintValidator() throws Exception { loginTenantAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java index e8639e0db7..faf0896b81 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java @@ -15,19 +15,29 @@ */ package org.thingsboard.server.edge; +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; import com.google.protobuf.AbstractMessage; import org.junit.Assert; import org.junit.Test; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -154,5 +164,115 @@ public class AssetEdgeTest extends AbstractEdgeTest { Assert.assertEquals(savedAsset.getUuidId().getLeastSignificantBits(), assetUpdateMsg.getIdLSB()); } + @Test + public void testSendAssetToCloud() throws Exception { + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AssetUpdateMsg.Builder assetUpdateMsgBuilder = AssetUpdateMsg.newBuilder(); + assetUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + assetUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + assetUpdateMsgBuilder.setName("Asset Edge 2"); + assetUpdateMsgBuilder.setType("test"); + assetUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(assetUpdateMsgBuilder); + uplinkMsgBuilder.addAssetUpdateMsg(assetUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + Asset asset = doGet("/api/asset/" + uuid, Asset.class); + Assert.assertNotNull(asset); + Assert.assertEquals("Asset Edge 2", asset.getName()); + } + + @Test + public void testSendAssetToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + String assetOnCloudName = StringUtils.randomAlphanumeric(15); + Asset assetOnCloud = saveAsset(assetOnCloudName); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AssetUpdateMsg.Builder assetUpdateMsgBuilder = AssetUpdateMsg.newBuilder(); + assetUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + assetUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + assetUpdateMsgBuilder.setName(assetOnCloudName); + assetUpdateMsgBuilder.setType("test"); + assetUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(assetUpdateMsgBuilder); + uplinkMsgBuilder.addAssetUpdateMsg(assetUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional assetUpdateMsgOpt = edgeImitator.findMessageByType(AssetUpdateMsg.class); + Assert.assertTrue(assetUpdateMsgOpt.isPresent()); + AssetUpdateMsg latestAssetUpdateMsg = assetUpdateMsgOpt.get(); + Assert.assertNotEquals(assetOnCloudName, latestAssetUpdateMsg.getName()); + + UUID newAssetId = new UUID(latestAssetUpdateMsg.getIdMSB(), latestAssetUpdateMsg.getIdLSB()); + + Assert.assertNotEquals(assetOnCloud.getUuidId(), newAssetId); + + Asset asset = doGet("/api/asset/" + newAssetId, Asset.class); + Assert.assertNotNull(asset); + Assert.assertNotEquals(assetOnCloudName, asset.getName()); + } + + @Test + public void testSendDeleteAssetOnEdgeToCloud() throws Exception { + Asset savedAsset = saveAssetOnCloudAndVerifyDeliveryToEdge(); + UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); + AssetUpdateMsg.Builder assetDeleteMsgBuilder = AssetUpdateMsg.newBuilder(); + assetDeleteMsgBuilder.setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE); + assetDeleteMsgBuilder.setIdMSB(savedAsset.getUuidId().getMostSignificantBits()); + assetDeleteMsgBuilder.setIdLSB(savedAsset.getUuidId().getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(assetDeleteMsgBuilder); + + upLinkMsgBuilder.addAssetUpdateMsg(assetDeleteMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); + AssetInfo assetInfo = doGet("/api/asset/info/" + savedAsset.getUuidId(), AssetInfo.class); + Assert.assertNotNull(assetInfo); + List edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/assets?", + new TypeReference>() { + }, new PageLink(100)).getData(); + Assert.assertFalse(edgeAssets.contains(assetInfo)); + } + + private Asset saveAssetOnCloudAndVerifyDeliveryToEdge() throws Exception { + // create asset and assign to edge + Asset savedAsset = saveAsset(StringUtils.randomAlphanumeric(15)); + edgeImitator.expectMessageAmount(1); // asset message + doPost("/api/edge/" + edge.getUuidId() + + "/asset/" + savedAsset.getUuidId(), Device.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + Optional assetUpdateMsgOpt = edgeImitator.findMessageByType(AssetUpdateMsg.class); + Assert.assertTrue(assetUpdateMsgOpt.isPresent()); + AssetUpdateMsg assetUpdateMsg = assetUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); + Assert.assertEquals(savedAsset.getUuidId().getMostSignificantBits(), assetUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAsset.getUuidId().getLeastSignificantBits(), assetUpdateMsg.getIdLSB()); + return savedAsset; + } } diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java index fd36c11db8..f77975f0a9 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java @@ -15,17 +15,22 @@ */ package org.thingsboard.server.edge; +import com.datastax.oss.driver.api.core.uuid.Uuids; import com.google.protobuf.AbstractMessage; import com.google.protobuf.ByteString; import org.junit.Assert; import org.junit.Test; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; import java.nio.charset.StandardCharsets; +import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -77,4 +82,56 @@ public class AssetProfileEdgeTest extends AbstractEdgeTest { unAssignFromEdgeAndDeleteRuleChain(buildingsRuleChainId); } + + @Test + public void testSendAssetProfileToCloud() throws Exception { + RuleChainId ruleChainId = createEdgeRuleChainAndAssignToEdge("Asset Profile Rule Chain"); + DashboardId dashboardId = createDashboardAndAssignToEdge("Asset Profile Dashboard"); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AssetProfileUpdateMsg.Builder assetProfileUpdateMsgBuilder = AssetProfileUpdateMsg.newBuilder(); + assetProfileUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + assetProfileUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + assetProfileUpdateMsgBuilder.setName("Asset Profile On Edge"); + assetProfileUpdateMsgBuilder.setDefault(false); + assetProfileUpdateMsgBuilder.setDefaultRuleChainIdMSB(ruleChainId.getId().getMostSignificantBits()); + assetProfileUpdateMsgBuilder.setDefaultRuleChainIdLSB(ruleChainId.getId().getLeastSignificantBits()); + assetProfileUpdateMsgBuilder.setDefaultDashboardIdMSB(dashboardId.getId().getMostSignificantBits()); + assetProfileUpdateMsgBuilder.setDefaultDashboardIdLSB(dashboardId.getId().getLeastSignificantBits()); + assetProfileUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(assetProfileUpdateMsgBuilder); + uplinkMsgBuilder.addAssetProfileUpdateMsg(assetProfileUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + AssetProfile assetProfile = doGet("/api/assetProfile/" + uuid, AssetProfile.class); + Assert.assertNotNull(assetProfile); + Assert.assertEquals("Asset Profile On Edge", assetProfile.getName()); + + // delete profile + edgeImitator.expectMessageAmount(1); + doDelete("/api/assetProfile/" + assetProfile.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AssetProfileUpdateMsg); + AssetProfileUpdateMsg assetProfileUpdateMsg = (AssetProfileUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, assetProfileUpdateMsg.getMsgType()); + Assert.assertEquals(assetProfile.getUuidId().getMostSignificantBits(), assetProfileUpdateMsg.getIdMSB()); + Assert.assertEquals(assetProfile.getUuidId().getLeastSignificantBits(), assetProfileUpdateMsg.getIdLSB()); + + // cleanup + unAssignFromEdgeAndDeleteDashboard(dashboardId); + unAssignFromEdgeAndDeleteRuleChain(ruleChainId); + } } diff --git a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java index 94743c4a6b..fd7dfbaafa 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.edge; +import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; import com.google.protobuf.AbstractMessage; import org.junit.Assert; @@ -22,13 +23,22 @@ import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.ShortCustomerInfo; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -149,4 +159,74 @@ public class DashboardEdgeTest extends AbstractEdgeTest { Assert.assertEquals(savedDashboard.getUuidId().getLeastSignificantBits(), dashboardUpdateMsg.getIdLSB()); } + @Test + public void testSendDashboardToCloud() throws Exception { + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + DashboardUpdateMsg.Builder dashboardUpdateMsgBuilder = DashboardUpdateMsg.newBuilder(); + dashboardUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + dashboardUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + dashboardUpdateMsgBuilder.setTitle("Edge Test Dashboard"); + dashboardUpdateMsgBuilder.setConfiguration(""); + dashboardUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(dashboardUpdateMsgBuilder); + uplinkMsgBuilder.addDashboardUpdateMsg(dashboardUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + Dashboard dashboard = doGet("/api/dashboard/" + uuid, Dashboard.class); + Assert.assertNotNull(dashboard); + Assert.assertEquals("Edge Test Dashboard", dashboard.getName()); + } + + @Test + public void testSendDeleteEntityViewOnEdgeToCloud() throws Exception { + Dashboard savedDashboard = saveDashboardOnCloudAndVerifyDeliveryToEdge(); + + UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); + DashboardUpdateMsg.Builder dashboardDeleteMsgBuilder = DashboardUpdateMsg.newBuilder(); + dashboardDeleteMsgBuilder.setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE); + dashboardDeleteMsgBuilder.setIdMSB(savedDashboard.getUuidId().getMostSignificantBits()); + dashboardDeleteMsgBuilder.setIdLSB(savedDashboard.getUuidId().getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(dashboardDeleteMsgBuilder); + + upLinkMsgBuilder.addDashboardUpdateMsg(dashboardDeleteMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); + DashboardInfo dashboardInfo = doGet("/api/dashboard/info/" + savedDashboard.getUuidId(), DashboardInfo.class); + Assert.assertNotNull(dashboardInfo); + List edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/dashboards?", + new TypeReference>() { + }, new PageLink(100)).getData(); + Assert.assertFalse(edgeAssets.contains(dashboardInfo)); + } + + private Dashboard saveDashboardOnCloudAndVerifyDeliveryToEdge() throws Exception { + // create dashboard and assign to edge + Dashboard dashboard = new Dashboard(); + dashboard.setTitle(StringUtils.randomAlphanumeric(15)); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + edgeImitator.expectMessageAmount(1); // dashboard message + doPost("/api/edge/" + edge.getUuidId() + + "/dashboard/" + savedDashboard.getUuidId(), Dashboard.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + Optional dashboardUpdateMsgOpt = edgeImitator.findMessageByType(DashboardUpdateMsg.class); + Assert.assertTrue(dashboardUpdateMsgOpt.isPresent()); + DashboardUpdateMsg entityViewUpdateMsg = dashboardUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); + Assert.assertEquals(savedDashboard.getUuidId().getMostSignificantBits(), entityViewUpdateMsg.getIdMSB()); + Assert.assertEquals(savedDashboard.getUuidId().getLeastSignificantBits(), entityViewUpdateMsg.getIdLSB()); + return savedDashboard; + } + } diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java index 9d4cc0ca15..4e8664aa97 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java @@ -15,19 +15,26 @@ */ package org.thingsboard.server.edge; +import com.datastax.oss.driver.api.core.uuid.Uuids; import com.google.protobuf.AbstractMessage; +import com.google.protobuf.ByteString; import org.junit.Assert; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; 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.OtaPackageInfo; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.device.data.PowerMode; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; import org.thingsboard.server.common.data.device.profile.CoapDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DefaultCoapDeviceTypeConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.Lwm2mDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; import org.thingsboard.server.common.data.device.profile.SnmpDeviceProfileTransportConfiguration; @@ -46,12 +53,15 @@ import org.thingsboard.server.common.data.transport.snmp.config.impl.TelemetryQu import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; import org.thingsboard.server.transport.AbstractTransportIntegrationTest; import org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -260,6 +270,67 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { removeDeviceProfileAndDoBasicAssert(deviceProfile); } + @Test + public void testSendDeviceProfileToCloud() throws Exception { + RuleChainId ruleChainId = createEdgeRuleChainAndAssignToEdge("Device Profile Rule Chain"); + DashboardId dashboardId = createDashboardAndAssignToEdge("Device Profile Dashboard"); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + DeviceProfileUpdateMsg.Builder deviceProfileUpdateMsgBuilder = DeviceProfileUpdateMsg.newBuilder(); + deviceProfileUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + deviceProfileUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + deviceProfileUpdateMsgBuilder.setName("Device Profile On Edge"); + deviceProfileUpdateMsgBuilder.setDefault(false); + deviceProfileUpdateMsgBuilder.setType(DeviceProfileType.DEFAULT.name()); + deviceProfileUpdateMsgBuilder.setProfileDataBytes(ByteString.copyFrom(dataDecodingEncodingService.encode(createProfileData()))); + deviceProfileUpdateMsgBuilder.setDefaultRuleChainIdMSB(ruleChainId.getId().getMostSignificantBits()); + deviceProfileUpdateMsgBuilder.setDefaultRuleChainIdLSB(ruleChainId.getId().getLeastSignificantBits()); + deviceProfileUpdateMsgBuilder.setDefaultDashboardIdMSB(dashboardId.getId().getMostSignificantBits()); + deviceProfileUpdateMsgBuilder.setDefaultDashboardIdLSB(dashboardId.getId().getLeastSignificantBits()); + deviceProfileUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(deviceProfileUpdateMsgBuilder); + uplinkMsgBuilder.addDeviceProfileUpdateMsg(deviceProfileUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + AssetProfile assetProfile = doGet("/api/deviceProfile/" + uuid, AssetProfile.class); + Assert.assertNotNull(assetProfile); + Assert.assertEquals("Device Profile On Edge", assetProfile.getName()); + + // delete profile + edgeImitator.expectMessageAmount(1); + doDelete("/api/deviceProfile/" + assetProfile.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof DeviceProfileUpdateMsg); + DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); + Assert.assertEquals(assetProfile.getUuidId().getMostSignificantBits(), deviceProfileUpdateMsg.getIdMSB()); + Assert.assertEquals(assetProfile.getUuidId().getLeastSignificantBits(), deviceProfileUpdateMsg.getIdLSB()); + + // cleanup + unAssignFromEdgeAndDeleteDashboard(dashboardId); + unAssignFromEdgeAndDeleteRuleChain(ruleChainId); + } + + private DeviceProfileData createProfileData() { + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setConfiguration(new DefaultDeviceProfileConfiguration()); + deviceProfileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + deviceProfileData.setProvisionConfiguration(new DisabledDeviceProfileProvisionConfiguration("Device Secret")); + return deviceProfileData; + } private DeviceProfile createDeviceProfileAndDoBasicAssert(String deviceProfileName, DeviceProfileTransportConfiguration deviceProfileTransportConfiguration) throws Exception { DeviceProfile deviceProfile = this.createDeviceProfile(deviceProfileName, deviceProfileTransportConfiguration); diff --git a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java index 75386dbb17..470fd8e576 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.edge; +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; import com.google.protobuf.AbstractMessage; import com.google.protobuf.InvalidProtocolBufferException; import org.junit.Assert; @@ -22,15 +24,24 @@ import org.junit.Test; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.EdgeEntityType; import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -43,11 +54,7 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { // create entity view and assign to edge edgeImitator.expectMessageAmount(1); Device device = findDeviceByName("Edge Device 1"); - EntityView entityView = new EntityView(); - entityView.setName("Edge EntityView 1"); - entityView.setType("test"); - entityView.setEntityId(device.getId()); - EntityView savedEntityView = doPost("/api/entityView", entityView, EntityView.class); + EntityView savedEntityView = saveEntityView("Edge EntityView 1", device.getId()); doPost("/api/edge/" + edge.getUuidId() + "/entityView/" + savedEntityView.getUuidId(), EntityView.class); Assert.assertTrue(edgeImitator.waitForMessages()); @@ -102,11 +109,7 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { // create entity view #2 and assign to edge edgeImitator.expectMessageAmount(1); - entityView = new EntityView(); - entityView.setName("Edge EntityView 2"); - entityView.setType("test"); - entityView.setEntityId(device.getId()); - savedEntityView = doPost("/api/entityView", entityView, EntityView.class); + savedEntityView = saveEntityView("Edge EntityView 2", device.getId()); doPost("/api/edge/" + edge.getUuidId() + "/entityView/" + savedEntityView.getUuidId(), EntityView.class); Assert.assertTrue(edgeImitator.waitForMessages()); @@ -158,6 +161,115 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { } + @Test + public void testSendEntityViewToCloud() throws Exception { + Device device = findDeviceByName("Edge Device 1"); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + EntityViewUpdateMsg.Builder entityViewUpdateMsgBuilder = EntityViewUpdateMsg.newBuilder(); + entityViewUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + entityViewUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + entityViewUpdateMsgBuilder.setName("Edge EntityView 2"); + entityViewUpdateMsgBuilder.setType("test"); + entityViewUpdateMsgBuilder.setEntityType(EdgeEntityType.DEVICE); + entityViewUpdateMsgBuilder.setEntityIdMSB(device.getUuidId().getMostSignificantBits()); + entityViewUpdateMsgBuilder.setEntityIdLSB(device.getUuidId().getLeastSignificantBits()); + entityViewUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(entityViewUpdateMsgBuilder); + uplinkMsgBuilder.addEntityViewUpdateMsg(entityViewUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + EntityView entityView = doGet("/api/entityView/" + uuid, EntityView.class); + Assert.assertNotNull(entityView); + Assert.assertEquals("Edge EntityView 2", entityView.getName()); + } + + @Test + public void testSendEntityViewToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + Device device = findDeviceByName("Edge Device 1"); + + String entityViewOnCloudName = StringUtils.randomAlphanumeric(15); + EntityView entityViewOnCloud = saveEntityView(entityViewOnCloudName, device.getId()); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + EntityViewUpdateMsg.Builder entityViewUpdateMsgBuilder = EntityViewUpdateMsg.newBuilder(); + entityViewUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + entityViewUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + entityViewUpdateMsgBuilder.setName(entityViewOnCloudName); + entityViewUpdateMsgBuilder.setType("test"); + entityViewUpdateMsgBuilder.setEntityType(EdgeEntityType.DEVICE); + entityViewUpdateMsgBuilder.setEntityIdMSB(device.getUuidId().getMostSignificantBits()); + entityViewUpdateMsgBuilder.setEntityIdLSB(device.getUuidId().getLeastSignificantBits()); + entityViewUpdateMsgBuilder.setMsgType(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + testAutoGeneratedCodeByProtobuf(entityViewUpdateMsgBuilder); + uplinkMsgBuilder.addEntityViewUpdateMsg(entityViewUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional entityViewUpdateMsgOpt = edgeImitator.findMessageByType(EntityViewUpdateMsg.class); + Assert.assertTrue(entityViewUpdateMsgOpt.isPresent()); + EntityViewUpdateMsg latestEntityViewUpdateMsg = entityViewUpdateMsgOpt.get(); + Assert.assertNotEquals(entityViewOnCloudName, latestEntityViewUpdateMsg.getName()); + + UUID newEntityViewId = new UUID(latestEntityViewUpdateMsg.getIdMSB(), latestEntityViewUpdateMsg.getIdLSB()); + + Assert.assertNotEquals(entityViewOnCloud.getId().getId(), newEntityViewId); + + EntityView entityView = doGet("/api/entityView/" + newEntityViewId, EntityView.class); + Assert.assertNotNull(entityView); + Assert.assertNotEquals(entityViewOnCloudName, entityView.getName()); + } + + @Test + public void testSendDeleteEntityViewOnEdgeToCloud() throws Exception { + Device device = findDeviceByName("Edge Device 1"); + EntityView savedEntityView = saveEntityViewOnCloudAndVerifyDeliveryToEdge(device); + + UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); + EntityViewUpdateMsg.Builder entityViewDeleteMsgBuilder = EntityViewUpdateMsg.newBuilder(); + entityViewDeleteMsgBuilder.setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE); + entityViewDeleteMsgBuilder.setIdMSB(savedEntityView.getUuidId().getMostSignificantBits()); + entityViewDeleteMsgBuilder.setIdLSB(savedEntityView.getUuidId().getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(entityViewDeleteMsgBuilder); + + upLinkMsgBuilder.addEntityViewUpdateMsg(entityViewDeleteMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); + EntityViewInfo entityViewInfo = doGet("/api/entityView/info/" + savedEntityView.getUuidId(), EntityViewInfo.class); + Assert.assertNotNull(entityViewInfo); + List edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/entityViews?", + new TypeReference>() { + }, new PageLink(100)).getData(); + Assert.assertFalse(edgeAssets.contains(entityViewInfo)); + } + private void verifyEntityViewUpdateMsg(EntityView entityView, Device device) throws InvalidProtocolBufferException { AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityViewUpdateMsg); @@ -173,6 +285,28 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { testAutoGeneratedCodeByProtobuf(entityViewUpdateMsg); } + private EntityView saveEntityViewOnCloudAndVerifyDeliveryToEdge(Device device) throws Exception { + // create entity view and assign to edge + EntityView savedEntityView = saveEntityView(StringUtils.randomAlphanumeric(15), device.getId()); + edgeImitator.expectMessageAmount(1); // entity view message + doPost("/api/edge/" + edge.getUuidId() + + "/entityView/" + savedEntityView.getUuidId(), EntityView.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + Optional entityViewUpdateMsgOpt = edgeImitator.findMessageByType(EntityViewUpdateMsg.class); + Assert.assertTrue(entityViewUpdateMsgOpt.isPresent()); + EntityViewUpdateMsg entityViewUpdateMsg = entityViewUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); + Assert.assertEquals(savedEntityView.getUuidId().getMostSignificantBits(), entityViewUpdateMsg.getIdMSB()); + Assert.assertEquals(savedEntityView.getUuidId().getLeastSignificantBits(), entityViewUpdateMsg.getIdLSB()); + return savedEntityView; + } + private EntityView saveEntityView(String name, DeviceId deviceId) { + EntityView entityView = new EntityView(); + entityView.setName(name); + entityView.setType("test"); + entityView.setEntityId(deviceId); + return doPost("/api/entityView", entityView, EntityView.class); + } } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 851550eee7..e9c0000565 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.notification; +import com.google.common.util.concurrent.SettableFuture; import lombok.extern.slf4j.Slf4j; import org.assertj.core.data.Offset; import org.java_websocket.client.WebSocketClient; @@ -23,6 +24,7 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.NotificationRuleId; import org.thingsboard.server.common.data.id.NotificationTargetId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; @@ -35,6 +37,7 @@ import org.thingsboard.server.common.data.notification.NotificationRequestStatus import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig; +import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; @@ -59,6 +62,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -470,6 +474,66 @@ public class NotificationApiTest extends AbstractNotificationApiTest { assertThat(stats.getSent().get(NotificationDeliveryMethod.WEB)).hasValue(1); } + @Test + public void testUserNotificationSettings() throws Exception { + var entityActionNotificationPref = new UserNotificationSettings.NotificationPref(); + entityActionNotificationPref.setEnabled(true); + entityActionNotificationPref.setEnabledDeliveryMethods(Map.of( + NotificationDeliveryMethod.WEB, true, + NotificationDeliveryMethod.SMS, false, + NotificationDeliveryMethod.EMAIL, false + )); + + var entitiesLimitNotificationPref = new UserNotificationSettings.NotificationPref(); + entitiesLimitNotificationPref.setEnabled(true); + entitiesLimitNotificationPref.setEnabledDeliveryMethods(Map.of( + NotificationDeliveryMethod.SMS, true, + NotificationDeliveryMethod.WEB, false, + NotificationDeliveryMethod.EMAIL, false + )); + + var apiUsageLimitNotificationPref = new UserNotificationSettings.NotificationPref(); + apiUsageLimitNotificationPref.setEnabled(false); + apiUsageLimitNotificationPref.setEnabledDeliveryMethods(Map.of( + NotificationDeliveryMethod.WEB, true, + NotificationDeliveryMethod.SMS, false, + NotificationDeliveryMethod.EMAIL, false + )); + + UserNotificationSettings settings = new UserNotificationSettings(Map.of( + NotificationType.ENTITY_ACTION, entityActionNotificationPref, + NotificationType.ENTITIES_LIMIT, entitiesLimitNotificationPref, + NotificationType.API_USAGE_LIMIT, apiUsageLimitNotificationPref + )); + doPost("/api/notification/settings/user", settings, UserNotificationSettings.class); + + var entityActionNotificationTemplate = createNotificationTemplate(NotificationType.ENTITY_ACTION, "Entity action", "Entity action", NotificationDeliveryMethod.WEB); + var entitiesLimitNotificationTemplate = createNotificationTemplate(NotificationType.ENTITIES_LIMIT, "Entities limit", "Entities limit", NotificationDeliveryMethod.WEB); + var apiUsageLimitNotificationTemplate = createNotificationTemplate(NotificationType.API_USAGE_LIMIT, "API usage limit", "API usage limit", NotificationDeliveryMethod.WEB); + NotificationTarget target = createNotificationTarget(tenantAdminUserId); + + NotificationRequest notificationRequest = NotificationRequest.builder() + .tenantId(tenantId) + .templateId(entityActionNotificationTemplate.getId()) + .originatorEntityId(tenantAdminUserId) + .targets(List.of(target.getUuidId())) + .ruleId(new NotificationRuleId(UUID.randomUUID())) // to trigger user settings check + .build(); + NotificationRequestStats stats = submitNotificationRequestAndWait(notificationRequest); + assertThat(stats.getErrors()).isEmpty(); + assertThat(stats.getSent().get(NotificationDeliveryMethod.WEB).get()).isOne(); + + notificationRequest.setTemplateId(entitiesLimitNotificationTemplate.getId()); + stats = submitNotificationRequestAndWait(notificationRequest); + assertThat(stats.getSent().get(NotificationDeliveryMethod.WEB)).matches(n -> n == null || n.get() == 0); + assertThat(stats.getErrors().get(NotificationDeliveryMethod.WEB).values()).first().asString().contains("disabled"); + + notificationRequest.setTemplateId(apiUsageLimitNotificationTemplate.getId()); + stats = submitNotificationRequestAndWait(notificationRequest); + assertThat(stats.getSent().get(NotificationDeliveryMethod.WEB)).matches(n -> n == null || n.get() == 0); + assertThat(stats.getErrors().get(NotificationDeliveryMethod.WEB).values()).first().asString().contains("disabled"); + } + @Test public void testSlackNotifications() throws Exception { NotificationSettings settings = new NotificationSettings(); @@ -524,6 +588,12 @@ public class NotificationApiTest extends AbstractNotificationApiTest { assertThat(stats.getErrors().get(NotificationDeliveryMethod.SLACK).values()).containsExactly(errorMessage); } + private NotificationRequestStats submitNotificationRequestAndWait(NotificationRequest notificationRequest) throws Exception { + SettableFuture future = SettableFuture.create(); + notificationCenter.processNotificationRequest(notificationRequest.getTenantId(), notificationRequest, future::set); + return future.get(30, TimeUnit.SECONDS); + } + private void checkFullNotificationsUpdate(UnreadNotificationsUpdate notificationsUpdate, String... expectedNotifications) { assertThat(notificationsUpdate.getNotifications()).extracting(Notification::getText).containsOnly(expectedNotifications); assertThat(notificationsUpdate.getNotifications()).extracting(Notification::getType).containsOnly(DEFAULT_NOTIFICATION_TYPE); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/MockNotificationSettingsService.java b/application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java similarity index 60% rename from application/src/test/java/org/thingsboard/server/service/notification/MockNotificationSettingsService.java rename to application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java index a49f8dc8cb..9b4ced8567 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/MockNotificationSettingsService.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java @@ -19,14 +19,20 @@ import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.notification.DefaultNotificationSettingsService; +import org.thingsboard.server.dao.notification.NotificationTargetService; +import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.user.UserSettingsService; @Service @Primary -public class MockNotificationSettingsService extends DefaultNotificationSettingsService { +public class TestNotificationSettingsService extends DefaultNotificationSettingsService { - public MockNotificationSettingsService(AdminSettingsService adminSettingsService) { - super(adminSettingsService, null, null, null); + public TestNotificationSettingsService(AdminSettingsService adminSettingsService, + NotificationTargetService notificationTargetService, + NotificationTemplateService notificationTemplateService, + UserSettingsService userSettingsService) { + super(adminSettingsService, notificationTargetService, notificationTemplateService, null, userSettingsService); } @Override diff --git a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java new file mode 100644 index 0000000000..80b9535af3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java @@ -0,0 +1,98 @@ +/** + * 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.service.queue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.RuleNodeException; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; + +import java.util.UUID; +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +class TbMsgPackCallbackTest { + + TenantId tenantId; + UUID msgId; + TbMsgPackProcessingContext ctx; + TbMsgPackCallback callback; + + @BeforeEach + void setUp() { + tenantId = TenantId.fromUUID(UUID.randomUUID()); + msgId = UUID.randomUUID(); + ctx = mock(TbMsgPackProcessingContext.class); + callback = spy(new TbMsgPackCallback(msgId, tenantId, ctx)); + } + + private static Stream testOnFailure_NotRateLimitException() { + return Stream.of( + Arguments.of(new RuleEngineException("rule engine no cause")), + Arguments.of(new RuleEngineException("rule engine caused 1 lvl", new RuntimeException())), + Arguments.of(new RuleEngineException("rule engine caused 2 lvl", new RuntimeException(new Exception()))), + Arguments.of(new RuleEngineException("rule engine caused 2 lvl Throwable", new RuntimeException(new Throwable()))), + Arguments.of(new RuleNodeException("rule node no cause", "RuleChain", new RuleNode())) + ); + } + + @ParameterizedTest + @MethodSource + void testOnFailure_NotRateLimitException(RuleEngineException ree) { + callback.onFailure(ree); + + verify(callback, never()).onRateLimit(any()); + verify(callback, never()).onSuccess(); + verify(ctx, never()).onSuccess(any()); + } + + private static Stream testOnFailure_RateLimitException() { + return Stream.of( + Arguments.of(new RuleEngineException("caused lvl 1", new TbRateLimitsException(EntityType.ASSET))), + Arguments.of(new RuleEngineException("caused lvl 2", new RuntimeException(new TbRateLimitsException(EntityType.ASSET)))), + Arguments.of( + new RuleEngineException("caused lvl 3", + new RuntimeException( + new Exception( + new TbRateLimitsException(EntityType.ASSET))))) + ); + } + + @ParameterizedTest + @MethodSource + void testOnFailure_RateLimitException(RuleEngineException ree) { + callback.onFailure(ree); + + verify(callback).onRateLimit(any()); + verify(callback).onFailure(any()); + verify(callback, never()).onSuccess(); + verify(ctx).onSuccess(msgId); + verify(ctx).onSuccess(any()); + verify(ctx, never()).onFailure(any(), any(), any()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index 52f2ec5c9d..ca30f9e5ed 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -22,19 +22,30 @@ 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.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.sql.query.EntityQueryRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -62,14 +73,32 @@ public class DefaultDeviceStateServiceTest { PartitionService partitionService; @Mock DeviceStateData deviceStateDataMock; + @Mock + EntityQueryRepository entityQueryRepository; + TenantId tenantId = new TenantId(UUID.fromString("00797a3b-7aeb-4b5b-b57a-c2a810d0f112")); DeviceId deviceId = DeviceId.fromString("00797a3b-7aeb-4b5b-b57a-c2a810d0f112"); + TopicPartitionInfo tpi; DefaultDeviceStateService service; + TelemetrySubscriptionService telemetrySubscriptionService; + @Before public void setUp() { - service = spy(new DefaultDeviceStateService(deviceService, attributesService, tsService, clusterService, partitionService, null, null, null, mock(NotificationRuleProcessor.class))); + service = spy(new DefaultDeviceStateService(deviceService, attributesService, tsService, clusterService, partitionService, entityQueryRepository, null, null, mock(NotificationRuleProcessor.class))); + telemetrySubscriptionService = Mockito.mock(TelemetrySubscriptionService.class); + ReflectionTestUtils.setField(service, "tsSubService", telemetrySubscriptionService); + ReflectionTestUtils.setField(service, "defaultStateCheckIntervalInSec", 60); + ReflectionTestUtils.setField(service, "defaultActivityStatsIntervalInSec", 60); + ReflectionTestUtils.setField(service, "initFetchPackSize", 10); + + tpi = TopicPartitionInfo.builder().myPartition(true).build(); + Mockito.when(partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId)).thenReturn(tpi); + Mockito.when(entityQueryRepository.findEntityDataByQueryInternal(Mockito.any())).thenReturn(new PageData<>()); + var deviceIdInfo = new DeviceIdInfo(tenantId.getId(), null, deviceId.getId()); + Mockito.when(deviceService.findDeviceIdInfos(Mockito.any())) + .thenReturn(new PageData<>(List.of(deviceIdInfo), 0, 1, false)); } @Test @@ -125,4 +154,188 @@ public class DefaultDeviceStateServiceTest { Assert.assertEquals(5000L, deviceStateData.getState().getInactivityTimeout()); } + private void initStateService(long timeout) throws InterruptedException { + service.stop(); + Mockito.reset(service, telemetrySubscriptionService); + ReflectionTestUtils.setField(service, "defaultInactivityTimeoutMs", timeout); + service.init(); + PartitionChangeEvent event = new PartitionChangeEvent(this, new QueueKey(ServiceType.TB_CORE), Collections.singleton(tpi)); + service.onApplicationEvent(event); + Thread.sleep(100); + } + + @Test + public void increaseInactivityForInactiveDeviceTest() throws Exception { + final long defaultTimeout = 1; + initStateService(defaultTimeout); + DeviceState deviceState = DeviceState.builder().build(); + DeviceStateData deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(new TbMsgMetaData()) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + Thread.sleep(defaultTimeout); + service.checkStates(); + activityVerify(false); + + Mockito.reset(telemetrySubscriptionService); + + long increase = 100; + long newTimeout = System.currentTimeMillis() - deviceState.getLastActivityTime() + increase; + + service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); + activityVerify(true); + Thread.sleep(increase); + service.checkStates(); + activityVerify(false); + + Mockito.reset(telemetrySubscriptionService); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + Thread.sleep(newTimeout + 5); + service.checkStates(); + activityVerify(false); + } + + @Test + public void increaseInactivityForActiveDeviceTest() throws Exception { + final long defaultTimeout = 1000; + initStateService(defaultTimeout); + DeviceState deviceState = DeviceState.builder().build(); + DeviceStateData deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(new TbMsgMetaData()) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + + Mockito.reset(telemetrySubscriptionService); + + long increase = 100; + long newTimeout = System.currentTimeMillis() - deviceState.getLastActivityTime() + increase; + + service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); + Mockito.verify(telemetrySubscriptionService, Mockito.never()).saveAttrAndNotify(Mockito.any(), Mockito.eq(deviceId), Mockito.any(), Mockito.eq("active"), Mockito.any(), Mockito.any()); + Thread.sleep(defaultTimeout + increase); + service.checkStates(); + activityVerify(false); + + Mockito.reset(telemetrySubscriptionService); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + Thread.sleep(newTimeout); + service.checkStates(); + activityVerify(false); + } + + @Test + public void increaseSmallInactivityForInactiveDeviceTest() throws Exception { + final long defaultTimeout = 1; + initStateService(defaultTimeout); + DeviceState deviceState = DeviceState.builder().build(); + DeviceStateData deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(new TbMsgMetaData()) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + Thread.sleep(defaultTimeout); + service.checkStates(); + activityVerify(false); + + Mockito.reset(telemetrySubscriptionService); + + long newTimeout = 1; + Thread.sleep(newTimeout); + Mockito.verify(telemetrySubscriptionService, Mockito.never()).saveAttrAndNotify(Mockito.any(), Mockito.eq(deviceId), Mockito.any(), Mockito.eq("active"), Mockito.any(), Mockito.any()); + } + + @Test + public void decreaseInactivityForActiveDeviceTest() throws Exception { + final long defaultTimeout = 1000; + initStateService(defaultTimeout); + DeviceState deviceState = DeviceState.builder().build(); + DeviceStateData deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(new TbMsgMetaData()) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + + Mockito.reset(telemetrySubscriptionService); + + Mockito.verify(telemetrySubscriptionService, Mockito.never()).saveAttrAndNotify(Mockito.any(), Mockito.eq(deviceId), Mockito.any(), Mockito.eq("active"), Mockito.any(), Mockito.any()); + + long newTimeout = 1; + + service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); + activityVerify(false); + Mockito.reset(telemetrySubscriptionService); + + service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, defaultTimeout); + activityVerify(true); + Thread.sleep(defaultTimeout); + service.checkStates(); + activityVerify(false); + } + + @Test + public void decreaseInactivityForInactiveDeviceTest() throws Exception { + final long defaultTimeout = 1000; + initStateService(defaultTimeout); + DeviceState deviceState = DeviceState.builder().build(); + DeviceStateData deviceStateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .state(deviceState) + .metaData(new TbMsgMetaData()) + .build(); + + service.deviceStates.put(deviceId, deviceStateData); + service.getPartitionedEntities(tpi).add(deviceId); + + service.onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + activityVerify(true); + Thread.sleep(defaultTimeout); + service.checkStates(); + activityVerify(false); + Mockito.reset(telemetrySubscriptionService); + + long newTimeout = 1; + + service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, newTimeout); + Mockito.verify(telemetrySubscriptionService, Mockito.never()).saveAttrAndNotify(Mockito.any(), Mockito.eq(deviceId), Mockito.any(), Mockito.eq("active"), Mockito.any(), Mockito.any()); + } + + private void activityVerify(boolean isActive) { + Mockito.verify(telemetrySubscriptionService, Mockito.times(1)).saveAttrAndNotify(Mockito.any(), Mockito.eq(deviceId), Mockito.any(), Mockito.eq("active"), Mockito.eq(isActive), Mockito.any()); + } + } \ No newline at end of file diff --git a/application/src/test/resources/application-test.properties b/application/src/test/resources/application-test.properties index 99055e0e5f..d96af8a910 100644 --- a/application/src/test/resources/application-test.properties +++ b/application/src/test/resources/application-test.properties @@ -68,3 +68,5 @@ sql.ttl.audit_logs.ttl=2592000 sql.edge_events.partition_size=168 sql.ttl.edge_events.edge_event_ttl=2592000 + +server.log_controller_error_stack_trace=false diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java index f89153feee..1ec6fb3fb5 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java @@ -31,6 +31,8 @@ public interface AssetProfileService extends EntityDaoService { AssetProfileInfo findAssetProfileInfoById(TenantId tenantId, AssetProfileId assetProfileId); + AssetProfile saveAssetProfile(AssetProfile assetProfile, boolean doValidate); + AssetProfile saveAssetProfile(AssetProfile assetProfile); void deleteAssetProfile(TenantId tenantId, AssetProfileId assetProfileId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index 8b825e0837..722eeff365 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -41,6 +41,8 @@ public interface AssetService extends EntityDaoService { Asset findAssetByTenantIdAndName(TenantId tenantId, String name); + Asset saveAsset(Asset asset, boolean doValidate); + Asset saveAsset(Asset asset); Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, CustomerId customerId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/dashboard/DashboardService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/dashboard/DashboardService.java index c9974bba69..434088180c 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/dashboard/DashboardService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/dashboard/DashboardService.java @@ -40,6 +40,8 @@ public interface DashboardService extends EntityDaoService { ListenableFuture findDashboardInfoByIdAsync(TenantId tenantId, DashboardId dashboardId); + Dashboard saveDashboard(Dashboard dashboard, boolean doValidate); + Dashboard saveDashboard(Dashboard dashboard); Dashboard assignDashboardToCustomer(TenantId tenantId, DashboardId dashboardId, CustomerId customerId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java index e765cc5030..f94b709c97 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java @@ -31,6 +31,8 @@ public interface DeviceProfileService extends EntityDaoService { DeviceProfileInfo findDeviceProfileInfoById(TenantId tenantId, DeviceProfileId deviceProfileId); + DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile, boolean doValidate); + DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile); void deleteDeviceProfile(TenantId tenantId, DeviceProfileId deviceProfileId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 31b2004a38..ea9dc31b45 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -38,6 +38,8 @@ public interface EntityViewService extends EntityDaoService { EntityView saveEntityView(EntityView entityView); + EntityView saveEntityView(EntityView entityView, boolean doValidate); + EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, CustomerId customerId); EntityView unassignEntityViewFromCustomer(TenantId tenantId, EntityViewId entityViewId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationSettingsService.java index a5433915b3..9fca174b3a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/notification/NotificationSettingsService.java @@ -16,7 +16,9 @@ package org.thingsboard.server.dao.notification; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; +import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; public interface NotificationSettingsService { @@ -24,6 +26,10 @@ public interface NotificationSettingsService { NotificationSettings findNotificationSettings(TenantId tenantId); + UserNotificationSettings saveUserNotificationSettings(TenantId tenantId, UserId userId, UserNotificationSettings settings); + + UserNotificationSettings getUserNotificationSettings(TenantId tenantId, UserId userId, boolean format); + void createDefaultNotificationConfigs(TenantId tenantId); void updateDefaultNotificationConfigs(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/AbstractRateLimitException.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/AbstractRateLimitException.java new file mode 100644 index 0000000000..1d1da75da3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/AbstractRateLimitException.java @@ -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. + */ +package org.thingsboard.server.common.data.exception; + +public abstract class AbstractRateLimitException extends RuntimeException { + + public AbstractRateLimitException() { + super(); + } + + public AbstractRateLimitException(String message) { + super(message); + } + + public AbstractRateLimitException(String message, Throwable cause) { + super(message, cause); + } + + public AbstractRateLimitException(Throwable cause) { + super(cause); + } + + protected AbstractRateLimitException(String message, Throwable cause, + boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ApiUsageLimitsExceededException.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ApiUsageLimitsExceededException.java index 2d24184a3f..aa9441c776 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ApiUsageLimitsExceededException.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ApiUsageLimitsExceededException.java @@ -15,7 +15,7 @@ */ package org.thingsboard.server.common.data.exception; -public class ApiUsageLimitsExceededException extends RuntimeException { +public class ApiUsageLimitsExceededException extends AbstractRateLimitException { public ApiUsageLimitsExceededException(String message) { super(message); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseDeleteTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseDeleteTsKvQuery.java index dee0e7aa9b..a97cc8f834 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseDeleteTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseDeleteTsKvQuery.java @@ -21,14 +21,20 @@ import lombok.Data; public class BaseDeleteTsKvQuery extends BaseTsKvQuery implements DeleteTsKvQuery { private final Boolean rewriteLatestIfDeleted; + private final Boolean deleteLatest; - public BaseDeleteTsKvQuery(String key, long startTs, long endTs, boolean rewriteLatestIfDeleted) { + public BaseDeleteTsKvQuery(String key, long startTs, long endTs, boolean rewriteLatestIfDeleted, boolean deleteLatest) { super(key, startTs, endTs); this.rewriteLatestIfDeleted = rewriteLatestIfDeleted; + this.deleteLatest = deleteLatest; + } + + public BaseDeleteTsKvQuery(String key, long startTs, long endTs, boolean rewriteLatestIfDeleted) { + this(key, startTs, endTs, rewriteLatestIfDeleted, true); } public BaseDeleteTsKvQuery(String key, long startTs, long endTs) { - this(key, startTs, endTs, false); + this(key, startTs, endTs, false, true); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/DeleteTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/DeleteTsKvQuery.java index 7b9b4ad16f..b2f41fffb4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/DeleteTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/DeleteTsKvQuery.java @@ -19,4 +19,6 @@ public interface DeleteTsKvQuery extends TsKvQuery { Boolean getRewriteLatestIfDeleted(); + Boolean getDeleteLatest(); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java b/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java index 206b203682..23149c79c1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java @@ -16,11 +16,10 @@ package org.thingsboard.server.common.data.msg; import lombok.Getter; +import org.thingsboard.server.common.data.StringUtils; -import java.util.Arrays; import java.util.EnumSet; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; public enum TbMsgType { @@ -39,10 +38,10 @@ public enum TbMsgType { ENTITY_UNASSIGNED("Entity Unassigned"), ATTRIBUTES_UPDATED("Attributes Updated"), ATTRIBUTES_DELETED("Attributes Deleted"), - ALARM(null), + ALARM, ALARM_ACK("Alarm Acknowledged"), ALARM_CLEAR("Alarm Cleared"), - ALARM_DELETE(null), + ALARM_DELETE, ALARM_ASSIGNED("Alarm Assigned"), ALARM_UNASSIGNED("Alarm Unassigned"), COMMENT_CREATED("Comment Created"), @@ -50,8 +49,8 @@ public enum TbMsgType { RPC_CALL_FROM_SERVER_TO_DEVICE("RPC Request to Device"), ENTITY_ASSIGNED_FROM_TENANT("Entity Assigned From Tenant"), ENTITY_ASSIGNED_TO_TENANT("Entity Assigned To Tenant"), - ENTITY_ASSIGNED_TO_EDGE(null), - ENTITY_UNASSIGNED_FROM_EDGE(null), + ENTITY_ASSIGNED_TO_EDGE, + ENTITY_UNASSIGNED_FROM_EDGE, TIMESERIES_UPDATED("Timeseries Updated"), TIMESERIES_DELETED("Timeseries Deleted"), RPC_QUEUED("RPC Queued"), @@ -65,9 +64,9 @@ public enum TbMsgType { RELATION_ADD_OR_UPDATE("Relation Added or Updated"), RELATION_DELETED("Relation Deleted"), RELATIONS_DELETED("All Relations Deleted"), - PROVISION_SUCCESS(null), - PROVISION_FAILURE(null), - SEND_EMAIL(null), + PROVISION_SUCCESS, + PROVISION_FAILURE, + SEND_EMAIL, // tellSelfOnly types GENERATOR_NODE_SELF_MSG(null, true), @@ -76,12 +75,15 @@ public enum TbMsgType { DEVICE_UPDATE_SELF_MSG(null, true), DEDUPLICATION_TIMEOUT_SELF_MSG(null, true), DELAY_TIMEOUT_SELF_MSG(null, true), - MSG_COUNT_SELF_MSG(null, true); + MSG_COUNT_SELF_MSG(null, true), + + // Custom or N/A type: + NA; public static final List NODE_CONNECTIONS = EnumSet.allOf(TbMsgType.class).stream() .filter(tbMsgType -> !tbMsgType.isTellSelfOnly()) .map(TbMsgType::getRuleNodeConnection) - .filter(Objects::nonNull) + .filter(connection -> !TbNodeConnectionType.OTHER.equals(connection)) .collect(Collectors.toUnmodifiableList()); @Getter @@ -91,25 +93,16 @@ public enum TbMsgType { private final boolean tellSelfOnly; TbMsgType(String ruleNodeConnection, boolean tellSelfOnly) { - this.ruleNodeConnection = ruleNodeConnection; + this.ruleNodeConnection = StringUtils.isNotEmpty(ruleNodeConnection) ? ruleNodeConnection : TbNodeConnectionType.OTHER; this.tellSelfOnly = tellSelfOnly; } TbMsgType(String ruleNodeConnection) { - this.ruleNodeConnection = ruleNodeConnection; - this.tellSelfOnly = false; + this(ruleNodeConnection, false); } - public static String getRuleNodeConnectionOrElseOther(String msgType) { - if (msgType == null) { - return TbNodeConnectionType.OTHER; - } else { - return Arrays.stream(TbMsgType.values()) - .filter(type -> type.name().equals(msgType)) - .findFirst() - .map(TbMsgType::getRuleNodeConnection) - .orElse(TbNodeConnectionType.OTHER); - } + TbMsgType() { + this(null, false); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java index 619e1ad38f..691ecf8bc1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java @@ -54,7 +54,6 @@ public class NotificationRequestStats { public void reportSent(NotificationDeliveryMethod deliveryMethod, NotificationRecipient recipient) { sent.computeIfAbsent(deliveryMethod, k -> new AtomicInteger()).incrementAndGet(); - processedRecipients.computeIfAbsent(deliveryMethod, k -> ConcurrentHashMap.newKeySet()).add(recipient.getId()); } public void reportError(NotificationDeliveryMethod deliveryMethod, Throwable error, NotificationRecipient recipient) { @@ -68,6 +67,10 @@ public class NotificationRequestStats { errors.computeIfAbsent(deliveryMethod, k -> new ConcurrentHashMap<>()).put(recipient.getTitle(), errorMessage); } + public void reportProcessed(NotificationDeliveryMethod deliveryMethod, Object recipientId) { + processedRecipients.computeIfAbsent(deliveryMethod, k -> ConcurrentHashMap.newKeySet()).add(recipientId); + } + public boolean contains(NotificationDeliveryMethod deliveryMethod, Object recipientId) { Set processedRecipients = this.processedRecipients.get(deliveryMethod); return processedRecipients != null && processedRecipients.contains(recipientId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java new file mode 100644 index 0000000000..0ad7cc6d78 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/UserNotificationSettings.java @@ -0,0 +1,82 @@ +/** + * 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.common.data.notification.settings; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; + +import javax.validation.Valid; +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotNull; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Data +public class UserNotificationSettings { + + @NotNull + @Valid + private final Map prefs; + + public static final UserNotificationSettings DEFAULT = new UserNotificationSettings(Collections.emptyMap()); + + public static final Set deliveryMethods = NotificationTargetType.PLATFORM_USERS.getSupportedDeliveryMethods(); + + @JsonCreator + public UserNotificationSettings(@JsonProperty("prefs") Map prefs) { + this.prefs = prefs; + } + + public boolean isEnabled(NotificationType notificationType, NotificationDeliveryMethod deliveryMethod) { + NotificationPref pref = prefs.get(notificationType); + if (pref == null) { + return true; + } + if (!pref.isEnabled()) { + return false; + } + return pref.getEnabledDeliveryMethods().getOrDefault(deliveryMethod, true); + } + + @Data + public static class NotificationPref { + private boolean enabled; + @NotNull + private Map enabledDeliveryMethods; + + public static NotificationPref createDefault() { + NotificationPref pref = new NotificationPref(); + pref.setEnabled(true); + pref.setEnabledDeliveryMethods(deliveryMethods.stream().collect(Collectors.toMap(v -> v, v -> true))); + return pref; + } + + @JsonIgnore + @AssertTrue(message = "Only email, Web and SMS delivery methods are allowed") + public boolean isValid() { + return enabledDeliveryMethods.entrySet().stream() + .allMatch(entry -> deliveryMethods.contains(entry.getKey()) && entry.getValue() != null); + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java b/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java index 5c540995aa..62a6377519 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java @@ -36,4 +36,6 @@ public class SortOrder { ASC, DESC } + public static final SortOrder BY_CREATED_TIME_DESC = new SortOrder("createdTime", Direction.DESC); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java index b19dbbbaee..cd627821ac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java @@ -19,7 +19,7 @@ import lombok.Getter; public enum UserSettingsType { - GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS, GETTING_STARTED; + GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS, GETTING_STARTED, NOTIFICATIONS; @Getter private final boolean reserved; diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java index a37eb31d72..870d5a2804 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java @@ -22,6 +22,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_DELETE; +import static org.thingsboard.server.common.data.msg.TbMsgType.NA; import static org.thingsboard.server.common.data.msg.TbMsgType.DEDUPLICATION_TIMEOUT_SELF_MSG; import static org.thingsboard.server.common.data.msg.TbMsgType.DELAY_TIMEOUT_SELF_MSG; import static org.thingsboard.server.common.data.msg.TbMsgType.ENTITY_ASSIGNED_TO_EDGE; @@ -51,37 +52,36 @@ class TbMsgTypeTest { DEVICE_UPDATE_SELF_MSG, DEDUPLICATION_TIMEOUT_SELF_MSG, DELAY_TIMEOUT_SELF_MSG, - MSG_COUNT_SELF_MSG + MSG_COUNT_SELF_MSG, + NA ); // backward-compatibility tests - + @Test void getRuleNodeConnectionsTest() { var tbMsgTypes = TbMsgType.values(); for (var type : tbMsgTypes) { if (typesWithNullRuleNodeConnection.contains(type)) { - assertThat(type.getRuleNodeConnection()).isNull(); + assertThat(type.getRuleNodeConnection()).isEqualTo(TbNodeConnectionType.OTHER); } else { - assertThat(type.getRuleNodeConnection()).isNotNull(); + assertThat(type.getRuleNodeConnection()).isNotEqualTo(TbNodeConnectionType.OTHER); } } } @Test void getRuleNodeConnectionOrElseOtherTest() { - assertThat(TbMsgType.getRuleNodeConnectionOrElseOther(null)) - .isEqualTo(TbNodeConnectionType.OTHER); var tbMsgTypes = TbMsgType.values(); for (var type : tbMsgTypes) { if (typesWithNullRuleNodeConnection.contains(type)) { - assertThat(TbMsgType.getRuleNodeConnectionOrElseOther(type.name())) + assertThat(type.getRuleNodeConnection()) .isEqualTo(TbNodeConnectionType.OTHER); } else { - assertThat(TbMsgType.getRuleNodeConnectionOrElseOther(type.name())).isNotNull() + assertThat(type.getRuleNodeConnection()).isNotNull() .isNotEqualTo(TbNodeConnectionType.OTHER); } } } - + } diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 4e8548aa33..dcd543a9a6 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -221,17 +221,11 @@ public class EdgeGrpcClient implements EdgeRpcClient { } @Override - public void sendSyncRequestMsg(boolean syncRequired) { - sendSyncRequestMsg(syncRequired, true); - } - - @Override - public void sendSyncRequestMsg(boolean syncRequired, boolean fullSync) { + public void sendSyncRequestMsg(boolean fullSyncRequired) { uplinkMsgLock.lock(); try { SyncRequestMsg syncRequestMsg = SyncRequestMsg.newBuilder() - .setSyncRequired(syncRequired) - .setFullSync(fullSync) + .setFullSync(fullSyncRequired) .build(); this.inputStream.onNext(RequestMsg.newBuilder() .setMsgType(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE) diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java index 44d00e22a8..9f2a303f5b 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java @@ -34,9 +34,7 @@ public interface EdgeRpcClient { void disconnect(boolean onError) throws InterruptedException; - void sendSyncRequestMsg(boolean syncRequired); - - void sendSyncRequestMsg(boolean syncRequired, boolean fullSync); + void sendSyncRequestMsg(boolean fullSyncRequired); void sendUplinkMsg(UplinkMsg uplinkMsg); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index a7924a2675..bc11349ecc 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -85,7 +85,7 @@ message ConnectResponseMsg { } message SyncRequestMsg { - bool syncRequired = 1; + bool syncRequired = 1; // deprecated optional bool fullSync = 2; } @@ -559,6 +559,11 @@ message UplinkMsg { repeated DeviceProfileDevicesRequestMsg deviceProfileDevicesRequestMsg = 13; // deprecated repeated WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg = 14; repeated EntityViewsRequestMsg entityViewsRequestMsg = 15; + repeated AssetUpdateMsg assetUpdateMsg = 16; + repeated DashboardUpdateMsg dashboardUpdateMsg = 17; + repeated EntityViewUpdateMsg entityViewUpdateMsg = 18; + repeated AssetProfileUpdateMsg assetProfileUpdateMsg = 19; + repeated DeviceProfileUpdateMsg deviceProfileUpdateMsg = 20; } message UplinkResponseMsg { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java index a987a4a253..63c1e27385 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java @@ -52,6 +52,7 @@ public final class TbMsg implements Serializable { private final UUID id; private final long ts; private final String type; + private final TbMsgType internalType; private final EntityId originator; private final CustomerId customerId; private final TbMsgMetaData metaData; @@ -97,7 +98,7 @@ public final class TbMsg implements Serializable { */ @Deprecated(since = "3.5.2") public static TbMsg newMsg(String queueName, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { - return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, + return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), null, type, originator, customerId, metaData.copy(), TbMsgDataType.JSON, data, ruleChainId, ruleNodeId, null, TbMsgCallback.EMPTY); } @@ -108,7 +109,7 @@ public final class TbMsg implements Serializable { @Deprecated(since = "3.5.2", forRemoval = true) public static TbMsg newMsg(String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), null, type, originator, customerId, metaData.copy(), TbMsgDataType.JSON, data, null, null, null, TbMsgCallback.EMPTY); } @@ -117,7 +118,7 @@ public final class TbMsg implements Serializable { } public static TbMsg newMsg(String queueName, TbMsgType type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { - return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), type.name(), originator, customerId, + return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, metaData.copy(), TbMsgDataType.JSON, data, ruleChainId, ruleNodeId, null, TbMsgCallback.EMPTY); } @@ -126,7 +127,7 @@ public final class TbMsg implements Serializable { } public static TbMsg newMsg(TbMsgType type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type.name(), originator, customerId, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, metaData.copy(), TbMsgDataType.JSON, data, null, null, null, TbMsgCallback.EMPTY); } @@ -170,13 +171,13 @@ public final class TbMsg implements Serializable { */ @Deprecated(since = "3.5.2") public static TbMsg newMsg(String queueName, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data) { - return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, + return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), null, type, originator, customerId, metaData.copy(), TbMsgDataType.JSON, data, null, null, null, TbMsgCallback.EMPTY); } @Deprecated(since = "3.5.2", forRemoval = true) public static TbMsg newMsg(String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), null, type, originator, customerId, metaData.copy(), dataType, data, null, null, null, TbMsgCallback.EMPTY); } @@ -205,12 +206,12 @@ public final class TbMsg implements Serializable { } public static TbMsg newMsg(String queueName, TbMsgType type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data) { - return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), type.name(), originator, customerId, + return new TbMsg(queueName, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, metaData.copy(), TbMsgDataType.JSON, data, null, null, null, TbMsgCallback.EMPTY); } public static TbMsg newMsg(TbMsgType type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type.name(), originator, customerId, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, customerId, metaData.copy(), dataType, data, null, null, null, TbMsgCallback.EMPTY); } @@ -222,13 +223,13 @@ public final class TbMsg implements Serializable { @Deprecated(since = "3.5.2", forRemoval = true) public static TbMsg newMsg(String type, EntityId originator, TbMsgMetaData metaData, TbMsgDataType dataType, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, null, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), null, type, originator, null, metaData.copy(), dataType, data, ruleChainId, ruleNodeId, null, TbMsgCallback.EMPTY); } @Deprecated(since = "3.5.2", forRemoval = true) public static TbMsg newMsg(String type, EntityId originator, TbMsgMetaData metaData, String data, TbMsgCallback callback) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, null, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), null, type, originator, null, metaData.copy(), TbMsgDataType.JSON, data, null, null, null, callback); } @@ -250,72 +251,77 @@ public final class TbMsg implements Serializable { */ @Deprecated(since = "3.5.2") public static TbMsg transformMsg(TbMsg tbMsg, String type, EntityId originator, TbMsgMetaData metaData, String data) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, type, originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, null, type, originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.callback); } public static TbMsg newMsg(TbMsgType type, EntityId originator, TbMsgMetaData metaData, TbMsgDataType dataType, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type.name(), originator, null, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, null, metaData.copy(), dataType, data, ruleChainId, ruleNodeId, null, TbMsgCallback.EMPTY); } public static TbMsg newMsg(TbMsgType type, EntityId originator, TbMsgMetaData metaData, String data, TbMsgCallback callback) { - return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type.name(), originator, null, + return new TbMsg(null, UUID.randomUUID(), System.currentTimeMillis(), type, originator, null, metaData.copy(), TbMsgDataType.JSON, data, null, null, null, callback); } public static TbMsg transformMsg(TbMsg tbMsg, TbMsgType type, EntityId originator, TbMsgMetaData metaData, String data) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, type.name(), originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, type, originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.callback); } public static TbMsg transformMsgOriginator(TbMsg tbMsg, EntityId originatorId) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.type, originatorId, tbMsg.getCustomerId(), tbMsg.metaData, tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, originatorId, tbMsg.getCustomerId(), tbMsg.metaData, tbMsg.dataType, tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgData(TbMsg tbMsg, String data) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgMetadata(TbMsg tbMsg, TbMsgMetaData metadata) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, tbMsg.customerId, metadata.copy(), tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, metadata.copy(), tbMsg.dataType, tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsg(TbMsg tbMsg, TbMsgMetaData metadata, String data) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, tbMsg.customerId, metadata, tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, metadata, tbMsg.dataType, data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgCustomerId(TbMsg tbMsg, CustomerId customerId) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, customerId, tbMsg.metaData, tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, customerId, tbMsg.metaData, tbMsg.dataType, tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgRuleChainId(TbMsg tbMsg, RuleChainId ruleChainId) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, tbMsg.data, ruleChainId, null, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgQueueName(TbMsg tbMsg, String queueName) { - return new TbMsg(queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, + return new TbMsg(queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, tbMsg.data, tbMsg.getRuleChainId(), null, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsg(TbMsg tbMsg, RuleChainId ruleChainId, String queueName) { - return new TbMsg(queueName, tbMsg.id, tbMsg.ts, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, + return new TbMsg(queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, tbMsg.data, ruleChainId, null, tbMsg.ctx.copy(), tbMsg.getCallback()); } //used for enqueueForTellNext public static TbMsg newMsg(TbMsg tbMsg, String queueName, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { - return new TbMsg(queueName, UUID.randomUUID(), tbMsg.getTs(), tbMsg.getType(), tbMsg.getOriginator(), tbMsg.customerId, tbMsg.getMetaData().copy(), + return new TbMsg(queueName, UUID.randomUUID(), tbMsg.getTs(), tbMsg.getInternalType(), tbMsg.getType(), tbMsg.getOriginator(), tbMsg.customerId, tbMsg.getMetaData().copy(), tbMsg.getDataType(), tbMsg.getData(), ruleChainId, ruleNodeId, tbMsg.ctx.copy(), TbMsgCallback.EMPTY); } - private TbMsg(String queueName, UUID id, long ts, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, + private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, + RuleChainId ruleChainId, RuleNodeId ruleNodeId, TbMsgProcessingCtx ctx, TbMsgCallback callback) { + this(queueName, id, ts, internalType, internalType.name(), originator, customerId, metaData, dataType, data, ruleChainId, ruleNodeId, ctx, callback); + } + + private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId, TbMsgProcessingCtx ctx, TbMsgCallback callback) { this.id = id; this.queueName = queueName; @@ -325,6 +331,7 @@ public final class TbMsg implements Serializable { this.ts = System.currentTimeMillis(); } this.type = type; + this.internalType = internalType != null ? internalType : getInternalType(type); this.originator = originator; if (customerId == null || customerId.isNullUid()) { if (originator != null && originator.getEntityType() == EntityType.CUSTOMER) { @@ -410,7 +417,7 @@ public final class TbMsg implements Serializable { } TbMsgDataType dataType = TbMsgDataType.values()[proto.getDataType()]; - return new TbMsg(queueName, UUID.fromString(proto.getId()), proto.getTs(), proto.getType(), entityId, customerId, + return new TbMsg(queueName, UUID.fromString(proto.getId()), proto.getTs(), null, proto.getType(), entityId, customerId, metaData, dataType, proto.getData(), ruleChainId, ruleNodeId, ctx, callback); } catch (InvalidProtocolBufferException e) { throw new IllegalStateException("Could not parse protobuf for TbMsg", e); @@ -422,17 +429,17 @@ public final class TbMsg implements Serializable { } public TbMsg copyWithRuleChainId(RuleChainId ruleChainId, UUID msgId) { - return new TbMsg(this.queueName, msgId, this.ts, this.type, this.originator, this.customerId, + return new TbMsg(this.queueName, msgId, this.ts, this.internalType, this.type, this.originator, this.customerId, this.metaData, this.dataType, this.data, ruleChainId, null, this.ctx, callback); } public TbMsg copyWithRuleNodeId(RuleChainId ruleChainId, RuleNodeId ruleNodeId, UUID msgId) { - return new TbMsg(this.queueName, msgId, this.ts, this.type, this.originator, this.customerId, + return new TbMsg(this.queueName, msgId, this.ts, this.internalType, this.type, this.originator, this.customerId, this.metaData, this.dataType, this.data, ruleChainId, ruleNodeId, this.ctx, callback); } public TbMsg copyWithNewCtx() { - return new TbMsg(this.queueName, this.id, this.ts, this.type, this.originator, this.customerId, + return new TbMsg(this.queueName, this.id, this.ts, this.internalType, this.type, this.originator, this.customerId, this.metaData, this.dataType, this.data, ruleChainId, ruleNodeId, this.ctx.copy(), TbMsgCallback.EMPTY); } @@ -468,8 +475,16 @@ public final class TbMsg implements Serializable { return ts; } + private TbMsgType getInternalType(String type) { + try { + return TbMsgType.valueOf(type); + } catch (IllegalArgumentException e) { + return TbMsgType.NA; + } + } + public boolean isTypeOf(TbMsgType tbMsgType) { - return tbMsgType != null && tbMsgType.name().equals(this.type); + return internalType.equals(tbMsgType); } public boolean isTypeOneOf(TbMsgType... types) { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleEngineException.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleEngineException.java index 1724a9050f..b8c713cc6b 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleEngineException.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleEngineException.java @@ -32,6 +32,11 @@ public class RuleEngineException extends Exception { ts = System.currentTimeMillis(); } + public RuleEngineException(String message, Throwable t) { + super(message != null ? message : "Unknown", t); + ts = System.currentTimeMillis(); + } + public String toJsonString() { try { return mapper.writeValueAsString(mapper.createObjectNode().put("message", getMessage())); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbMsgCallback.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbMsgCallback.java index 3312c98b64..6cf298adb2 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbMsgCallback.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbMsgCallback.java @@ -39,6 +39,10 @@ public interface TbMsgCallback { void onFailure(RuleEngineException e); + default void onRateLimit(RuleEngineException e) { + onFailure(e); + }; + /** * Returns 'true' if rule engine is expecting the message to be processed, 'false' otherwise. * message may no longer be valid, if the message pack is already expired/canceled/failed. diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java index dd5fda4dd5..5e63cb037e 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java @@ -17,11 +17,12 @@ package org.thingsboard.server.common.msg.tools; import lombok.Getter; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.exception.AbstractRateLimitException; /** * Created by ashvayka on 22.10.18. */ -public class TbRateLimitsException extends RuntimeException { +public class TbRateLimitsException extends AbstractRateLimitException { @Getter private final EntityType entityType; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueCallbackWrapper.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueCallbackWrapper.java index f14c9a047b..f102e34666 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueCallbackWrapper.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueCallbackWrapper.java @@ -40,6 +40,6 @@ public class MultipleTbQueueCallbackWrapper implements TbQueueCallback { @Override public void onFailure(Throwable t) { - callback.onFailure(new RuleEngineException(t.getMessage())); + callback.onFailure(new RuleEngineException(t.getMessage(), t)); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueTbMsgCallbackWrapper.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueTbMsgCallbackWrapper.java index e812628f16..b3bd05ca5e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueTbMsgCallbackWrapper.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueTbMsgCallbackWrapper.java @@ -41,6 +41,6 @@ public class MultipleTbQueueTbMsgCallbackWrapper implements TbQueueCallback { @Override public void onFailure(Throwable t) { - tbMsgCallback.onFailure(new RuleEngineException(t.getMessage())); + tbMsgCallback.onFailure(new RuleEngineException(t.getMessage(), t)); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/TbQueueTbMsgCallbackWrapper.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/TbQueueTbMsgCallbackWrapper.java index 413ef11e30..1107115085 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/TbQueueTbMsgCallbackWrapper.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/TbQueueTbMsgCallbackWrapper.java @@ -35,6 +35,6 @@ public class TbQueueTbMsgCallbackWrapper implements TbQueueCallback { @Override public void onFailure(Throwable t) { - tbMsgCallback.onFailure(new RuleEngineException(t.getMessage())); + tbMsgCallback.onFailure(new RuleEngineException(t.getMessage(), t)); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java index 44999d016a..e99817de17 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java @@ -69,7 +69,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi private Integer zkSessionTimeout; @Value("${zk.zk_dir}") private String zkDir; - @Value("${zk.recalculate_delay:60000}") + @Value("${zk.recalculate_delay:0}") private Long recalculateDelay; protected final ConcurrentHashMap> delayedTasks; diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java index 15c2f04d17..850f5c08ea 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java @@ -30,6 +30,9 @@ import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.TbQueueProducer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; import java.util.Properties; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -53,10 +56,14 @@ public class TbKafkaProducerTemplate implements TbQueuePro private final Set topics; + @Getter + private final String clientId; + @Builder private TbKafkaProducerTemplate(TbKafkaSettings settings, String defaultTopic, String clientId, TbQueueAdmin admin) { Properties props = settings.toProducerProps(); + this.clientId = Objects.requireNonNull(clientId, "Kafka producer client.id is null"); if (!StringUtils.isEmpty(clientId)) { props.put(ProducerConfig.CLIENT_ID_CONFIG, clientId); } @@ -72,6 +79,22 @@ public class TbKafkaProducerTemplate implements TbQueuePro public void init() { } + void addAnalyticHeaders(List headers) { + headers.add(new RecordHeader("_producerId", getClientId().getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("_threadName", Thread.currentThread().getName().getBytes(StandardCharsets.UTF_8))); + if (log.isTraceEnabled()) { + try { + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + int maxlevel = Math.min(stackTrace.length, 10); + for (int i = 2; i < maxlevel; i++) { // ignore two levels: getStackTrace and addAnalyticHeaders + headers.add(new RecordHeader("_stackTrace" + i, stackTrace[i].toString().getBytes(StandardCharsets.UTF_8))); + } + } catch (Throwable t) { + log.trace("Failed to add stacktrace headers in Kafka producer {}", getClientId(), t); + } + } + } + @Override public void send(TopicPartitionInfo tpi, T msg, TbQueueCallback callback) { try { @@ -79,7 +102,10 @@ public class TbKafkaProducerTemplate implements TbQueuePro String key = msg.getKey().toString(); byte[] data = msg.getData(); ProducerRecord record; - Iterable headers = msg.getHeaders().getData().entrySet().stream().map(e -> new RecordHeader(e.getKey(), e.getValue())).collect(Collectors.toList()); + List headers = msg.getHeaders().getData().entrySet().stream().map(e -> new RecordHeader(e.getKey(), e.getValue())).collect(Collectors.toList()); + if (log.isDebugEnabled()) { + addAnalyticHeaders(headers); + } record = new ProducerRecord<>(tpi.getFullTopicName(), null, key, data, headers); producer.send(record, (metadata, exception) -> { if (exception == null) { diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplateTest.java new file mode 100644 index 0000000000..bfd3c4a6dc --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplateTest.java @@ -0,0 +1,54 @@ +/** + * 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.kafka; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.common.header.Header; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.queue.TbQueueMsg; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willCallRealMethod; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; + +@Slf4j +class TbKafkaProducerTemplateTest { + + TbKafkaProducerTemplate producerTemplate; + + @BeforeEach + void setUp() { + producerTemplate = mock(TbKafkaProducerTemplate.class); + willCallRealMethod().given(producerTemplate).addAnalyticHeaders(any()); + willReturn("tb-core-to-core-notifications-tb-core-3").given(producerTemplate).getClientId(); + } + + @Test + void testAddAnalyticHeaders() { + List headers = new ArrayList<>(); + producerTemplate.addAnalyticHeaders(headers); + assertThat(headers).isNotEmpty(); + headers.forEach(r -> log.info("RecordHeader key [{}] value [{}]", r.key(), new String(r.value(), StandardCharsets.UTF_8))); + } + +} diff --git a/common/queue/src/test/resources/logback-test.xml b/common/queue/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..f7053313d4 --- /dev/null +++ b/common/queue/src/test/resources/logback-test.xml @@ -0,0 +1,20 @@ + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java index d6958137c7..bd02d9fcc4 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -30,6 +30,7 @@ import org.thingsboard.server.coapserver.TbCoapDtlsSessionInfo; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.security.DeviceTokenCredentials; import org.thingsboard.server.common.msg.session.FeatureType; @@ -379,12 +380,16 @@ public class CoapTransportResource extends AbstractCoapTransportResource { } } - private Optional getFeatureType(Request request) { + protected Optional getFeatureType(Request request) { List uriPath = request.getOptions().getUriPath(); try { - if (uriPath.size() >= FEATURE_TYPE_POSITION) { + int size = uriPath.size(); + if (size >= FEATURE_TYPE_POSITION) { + if (size == FEATURE_TYPE_POSITION && StringUtils.isNumeric(uriPath.get(size - 1))) { + return Optional.of(FeatureType.valueOf(uriPath.get(FEATURE_TYPE_POSITION - 2).toUpperCase())); + } return Optional.of(FeatureType.valueOf(uriPath.get(FEATURE_TYPE_POSITION - 1).toUpperCase())); - } else if (uriPath.size() >= FEATURE_TYPE_POSITION_CERTIFICATE_REQUEST) { + } else if (size == FEATURE_TYPE_POSITION_CERTIFICATE_REQUEST) { if (uriPath.contains(DataConstants.PROVISION)) { return Optional.of(FeatureType.valueOf(DataConstants.PROVISION.toUpperCase())); } diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapTransportResourceTest.java new file mode 100644 index 0000000000..c7f33e3694 --- /dev/null +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapTransportResourceTest.java @@ -0,0 +1,161 @@ +/** + * 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.coap; + +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.coap.OptionSet; +import org.eclipse.californium.core.coap.Request; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.coapserver.CoapServerService; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.msg.session.FeatureType; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.queue.scheduler.SchedulerComponent; +import org.thingsboard.server.transport.coap.client.CoapClientContext; + +import java.util.Random; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CoapTransportResourceTest { + + private static final String V1 = "v1"; + private static final String API = "api"; + private static final String TELEMETRY = "telemetry"; + private static final String ATTRIBUTES = "attributes"; + private static final String RPC = "rpc"; + private static final String CLAIM = "claim"; + private static final String PROVISION = "provision"; + private static final String GET_ATTRIBUTES_URI_QUERY = "clientKeys=attribute1,attribute2&sharedKeys=shared1,shared2"; + + private static final Random RANDOM = new Random(); + + private static CoapTransportResource coapTransportResource; + + @BeforeAll + static void setUp() { + + var ctxMock = mock(CoapTransportContext.class); + var coapServerServiceMock = mock(CoapServerService.class); + var transportServiceMock = mock(TransportService.class); + var clientContextMock = mock(CoapClientContext.class); + var schedulerComponentMock = mock(SchedulerComponent.class); + + when(ctxMock.getTransportService()).thenReturn(transportServiceMock); + when(ctxMock.getClientContext()).thenReturn(clientContextMock); + when(ctxMock.getSessionReportTimeout()).thenReturn(1L); + when(ctxMock.getScheduler()).thenReturn(schedulerComponentMock); + + coapTransportResource = new CoapTransportResource(ctxMock, coapServerServiceMock, V1); + } + + @ParameterizedTest + @MethodSource("provideRequestAndFeatureType") + void givenRequest_whenGetFeatureType_thenReturnedExpectedFeatureType(Request request, FeatureType expectedFeatureType) { + var featureTypeOptional = coapTransportResource.getFeatureType(request); + + assertTrue(featureTypeOptional.isPresent(), "Optional is empty"); + assertEquals(expectedFeatureType, featureTypeOptional.get(), "Feature type is invalid"); + } + + static Stream provideRequestAndFeatureType() { + return Stream.of( + // accessToken based tests + Arguments.of(toAccessTokenRequest(CoAP.Code.POST, TELEMETRY), FeatureType.TELEMETRY), + Arguments.of(toAccessTokenRequest(CoAP.Code.POST, ATTRIBUTES), FeatureType.ATTRIBUTES), + Arguments.of(toGetAttributesAccessTokenRequest(), FeatureType.ATTRIBUTES), + Arguments.of(toAccessTokenRequest(CoAP.Code.GET, ATTRIBUTES), FeatureType.ATTRIBUTES), + Arguments.of(toAccessTokenRequest(CoAP.Code.GET, RPC), FeatureType.RPC), + Arguments.of(toRpcResponseAccessTokenRequest(), FeatureType.RPC), + Arguments.of(toAccessTokenRequest(CoAP.Code.POST, RPC), FeatureType.RPC), + Arguments.of(toAccessTokenRequest(CoAP.Code.POST, CLAIM), FeatureType.CLAIM), + // certificate based tests + Arguments.of(toCertificateRequest(CoAP.Code.POST, TELEMETRY), FeatureType.TELEMETRY), + Arguments.of(toCertificateRequest(CoAP.Code.POST, ATTRIBUTES), FeatureType.ATTRIBUTES), + Arguments.of(toGetAttributesCertificateRequest(), FeatureType.ATTRIBUTES), + Arguments.of(toCertificateRequest(CoAP.Code.GET, ATTRIBUTES), FeatureType.ATTRIBUTES), + Arguments.of(toCertificateRequest(CoAP.Code.GET, RPC), FeatureType.RPC), + Arguments.of(toRpcResponseCertificateRequest(), FeatureType.RPC), + Arguments.of(toCertificateRequest(CoAP.Code.POST, RPC), FeatureType.RPC), + Arguments.of(toCertificateRequest(CoAP.Code.POST, CLAIM), FeatureType.CLAIM), + // provision request + Arguments.of(toProvisionRequest(), FeatureType.PROVISION) + ); + } + + private static Request toAccessTokenRequest(CoAP.Code method, String featureType) { + return getAccessTokenRequest(method, featureType, null, null); + } + + private static Request toGetAttributesAccessTokenRequest() { + return getAccessTokenRequest(CoAP.Code.GET, CoapTransportResourceTest.ATTRIBUTES, null, CoapTransportResourceTest.GET_ATTRIBUTES_URI_QUERY); + } + + private static Request toRpcResponseAccessTokenRequest() { + return getAccessTokenRequest(CoAP.Code.POST, CoapTransportResourceTest.RPC, RANDOM.nextInt(100), null); + } + + private static Request toCertificateRequest(CoAP.Code method, String featureType) { + return getCertificateRequest(method, featureType, null, null); + } + + private static Request toGetAttributesCertificateRequest() { + return getCertificateRequest(CoAP.Code.GET, CoapTransportResourceTest.ATTRIBUTES, null, CoapTransportResourceTest.GET_ATTRIBUTES_URI_QUERY); + } + + private static Request toRpcResponseCertificateRequest() { + return getCertificateRequest(CoAP.Code.POST, CoapTransportResourceTest.RPC, RANDOM.nextInt(100), null); + } + + private static Request getAccessTokenRequest(CoAP.Code method, String featureType, Integer requestId, String uriQuery) { + return getRequest(method, featureType, false, requestId, uriQuery); + } + + private static Request getCertificateRequest(CoAP.Code method, String featureType, Integer requestId, String uriQuery) { + return getRequest(method, featureType, true, requestId, uriQuery); + } + + private static Request toProvisionRequest() { + return getRequest(CoAP.Code.POST, PROVISION, true, null, null); + } + + private static Request getRequest(CoAP.Code method, String featureType, boolean dtls, Integer requestId, String uriQuery) { + var request = new Request(method); + var options = new OptionSet(); + options.addUriPath(API); + options.addUriPath(V1); + if (!dtls) { + options.addUriPath(StringUtils.randomAlphanumeric(20)); + } + options.addUriPath(featureType); + if (requestId != null) { + options.addUriPath(String.valueOf(requestId)); + } + if (uriQuery != null) { + options.setUriQuery(uriQuery); + } + request.setOptions(options); + return request; + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java b/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java new file mode 100644 index 0000000000..2ec15f3724 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/ExceptionUtil.java @@ -0,0 +1,67 @@ +/** + * 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.common.util; + +import com.google.gson.JsonParseException; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; + +import javax.script.ScriptException; +import java.io.PrintWriter; +import java.io.StringWriter; + +@Slf4j +public class ExceptionUtil { + + @SuppressWarnings("unchecked") + public static T lookupException(Throwable source, Class clazz) { + Exception e = lookupExceptionInCause(source, clazz); + if (e != null) { + return (T) e; + } else { + return null; + } + } + + public static Exception lookupExceptionInCause(Throwable source, Class extends Exception>... clazzes) { + while (source != null) { + for (Class extends Exception> clazz : clazzes) { + if (clazz.isAssignableFrom(source.getClass())) { + return (Exception) source; + } + } + source = source.getCause(); + } + return null; + } + + public static String toString(Exception e, EntityId componentId, boolean stackTraceEnabled) { + Exception exception = lookupExceptionInCause(e, ScriptException.class, JsonParseException.class); + if (exception != null && StringUtils.isNotEmpty(exception.getMessage())) { + return exception.getMessage(); + } else { + if (stackTraceEnabled) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } else { + log.debug("[{}] Unknown error during message processing", componentId, e); + return "Please contact system administrator"; + } + } + } +} diff --git a/common/util/src/test/java/org/thingsboard/common/util/ExceptionUtilTest.java b/common/util/src/test/java/org/thingsboard/common/util/ExceptionUtilTest.java new file mode 100644 index 0000000000..e589ae8e30 --- /dev/null +++ b/common/util/src/test/java/org/thingsboard/common/util/ExceptionUtilTest.java @@ -0,0 +1,74 @@ +/** + * 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.common.util; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExceptionUtilTest { + + final Exception cause = new RuntimeException(); + + @Test + void givenRootCause_whenLookupExceptionInCause_thenReturnRootCauseAndNoStackOverflow() { + Exception e = cause; + for (int i = 0; i <= 16384; i++) { + e = new Exception(e); + } + assertThat(ExceptionUtil.lookupExceptionInCause(e, RuntimeException.class)).isSameAs(cause); + } + + @Test + void givenCause_whenLookupExceptionInCause_thenReturnCause() { + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(cause), RuntimeException.class)).isSameAs(cause); + } + + @Test + void givenNoCauseAndExceptionIsWantedCauseClass_whenLookupExceptionInCause_thenReturnSelf() { + assertThat(ExceptionUtil.lookupExceptionInCause(cause, RuntimeException.class)).isSameAs(cause); + } + + @Test + void givenNoCause_whenLookupExceptionInCause_thenReturnNull() { + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(), RuntimeException.class)).isNull(); + } + + @Test + void givenNotWantedCause_whenLookupExceptionInCause_thenReturnNull() { + final Exception cause = new IOException(); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(cause), RuntimeException.class)).isNull(); + } + + @Test + void givenCause_whenLookupExceptionInCauseByMany_thenReturnFirstCause() { + final Exception causeIAE = new IllegalAccessException(); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIAE))).isNull(); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIAE), IOException.class, NoSuchFieldException.class)).isNull(); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIAE), IllegalAccessException.class, IOException.class, NoSuchFieldException.class)).isSameAs(causeIAE); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIAE), IOException.class, NoSuchFieldException.class, IllegalAccessException.class)).isSameAs(causeIAE); + + final Exception causeIOE = new IOException(causeIAE); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIOE))).isNull(); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIAE), ClassNotFoundException.class, NoSuchFieldException.class)).isNull(); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIOE), IOException.class, NoSuchFieldException.class)).isSameAs(causeIOE); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIOE), IllegalAccessException.class, IOException.class, NoSuchFieldException.class)).isSameAs(causeIOE); + assertThat(ExceptionUtil.lookupExceptionInCause(new Exception(causeIOE), IOException.class, NoSuchFieldException.class, IllegalAccessException.class)).isSameAs(causeIOE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java index b3bc89c34d..9240bbc223 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java @@ -111,10 +111,24 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService prefs = new EnumMap<>(NotificationType.class); + if (settings != null) { + prefs.putAll(settings.getPrefs()); + } + NotificationPref defaultPref = NotificationPref.createDefault(); + for (NotificationType notificationType : NotificationType.values()) { + NotificationPref pref = prefs.get(notificationType); + if (pref == null) { + prefs.put(notificationType, defaultPref); + } else { + var enabledDeliveryMethods = new LinkedHashMap<>(pref.getEnabledDeliveryMethods()); + // in case a new delivery method was added to the platform + UserNotificationSettings.deliveryMethods.forEach(deliveryMethod -> { + enabledDeliveryMethods.putIfAbsent(deliveryMethod, true); + }); + pref.setEnabledDeliveryMethods(enabledDeliveryMethods); + } + } + return new UserNotificationSettings(prefs); + } + @Transactional(propagation = Propagation.NOT_SUPPORTED) // so that parent transaction is not aborted on method failure @Override public void createDefaultNotificationConfigs(TenantId tenantId) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java index 5fa156725d..3d24c6221f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationDao.java @@ -81,6 +81,9 @@ public class JpaNotificationDao extends JpaAbstractDao> futures, EntityId entityId, DeleteTsKvQuery query) { futures.add(Futures.transform(timeseriesDao.remove(tenantId, entityId, query), v -> null, MoreExecutors.directExecutor())); - futures.add(timeseriesLatestDao.removeLatest(tenantId, entityId, query)); + if (query.getDeleteLatest()) { + futures.add(timeseriesLatestDao.removeLatest(tenantId, entityId, query)); + } } private static void validate(EntityId entityId) { diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index 12e0bfddba..675fcd3ec0 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -113,3 +113,5 @@ CREATE INDEX IF NOT EXISTS idx_notification_request_status ON notification_reque CREATE INDEX IF NOT EXISTS idx_notification_id ON notification(id); CREATE INDEX IF NOT EXISTS idx_notification_recipient_id_created_time ON notification(recipient_id, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_notification_recipient_id_unread ON notification(recipient_id) WHERE status <> 'READ'; diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index 893c8b565c..5a7e9b631d 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.msa.connectivity; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; @@ -28,6 +29,7 @@ import lombok.extern.slf4j.Slf4j; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import org.thingsboard.common.util.AbstractListeningExecutor; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; @@ -74,8 +76,18 @@ import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevi public class MqttClientTest extends AbstractContainerTest { private Device device; + AbstractListeningExecutor handlerExecutor; + @BeforeMethod public void setUp() throws Exception { + this.handlerExecutor = new AbstractListeningExecutor() { + @Override + protected int getThreadPollSize() { + return 4; + } + }; + handlerExecutor.init(); + testRestClient.login("tenant@thingsboard.org", "tenant"); device = testRestClient.postDevice("", defaultDevicePrototype("http_")); } @@ -83,6 +95,9 @@ public class MqttClientTest extends AbstractContainerTest { @AfterMethod public void tearDown() { testRestClient.deleteDeviceIfExists(device.getId()); + if (handlerExecutor != null) { + handlerExecutor.destroy(); + } } @Test public void telemetryUpload() throws Exception { @@ -461,11 +476,16 @@ public class MqttClientTest extends AbstractContainerTest { return getMqttClient(deviceCredentials.getCredentialsId(), listener); } + private String getOwnerId() { + return "Tenant[" + device.getTenantId().getId() + "]MqttClientTestDevice[" + device.getId().getId() + "]"; + } + private MqttClient getMqttClient(String username, MqttMessageListener listener) throws InterruptedException, ExecutionException { MqttClientConfig clientConfig = new MqttClientConfig(); + clientConfig.setOwnerId(getOwnerId()); clientConfig.setClientId("MQTT client from test"); clientConfig.setUsername(username); - MqttClient mqttClient = MqttClient.create(clientConfig, listener); + MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor); mqttClient.connect("localhost", 1883).get(); return mqttClient; } @@ -479,9 +499,10 @@ public class MqttClientTest extends AbstractContainerTest { } @Override - public void onMessage(String topic, ByteBuf message) { + public ListenableFuture onMessage(String topic, ByteBuf message) { log.info("MQTT message [{}], topic [{}]", message.toString(StandardCharsets.UTF_8), topic); events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8))); + return Futures.immediateVoidFuture(); } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index a038d4cf50..5e2a9f5fe6 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.msa.connectivity; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; @@ -32,6 +33,7 @@ import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import org.thingsboard.common.util.AbstractListeningExecutor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.mqtt.MqttClient; @@ -76,8 +78,18 @@ public class MqttGatewayClientTest extends AbstractContainerTest { private MqttMessageListener listener; private JsonParser jsonParser = new JsonParser(); + AbstractListeningExecutor handlerExecutor; + @BeforeMethod public void createGateway() throws Exception { + this.handlerExecutor = new AbstractListeningExecutor() { + @Override + protected int getThreadPollSize() { + return 4; + } + }; + handlerExecutor.init(); + testRestClient.login("tenant@thingsboard.org", "tenant"); gatewayDevice = testRestClient.postDevice("", defaultGatewayPrototype()); DeviceCredentials gatewayDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(gatewayDevice.getId()); @@ -94,6 +106,9 @@ public class MqttGatewayClientTest extends AbstractContainerTest { this.listener = null; this.mqttClient = null; this.createdDevice = null; + if (handlerExecutor != null) { + handlerExecutor.destroy(); + } } @Test @@ -403,11 +418,16 @@ public class MqttGatewayClientTest extends AbstractContainerTest { return testRestClient.getDeviceById(createdDeviceId); } + private String getOwnerId() { + return "Tenant[" + gatewayDevice.getTenantId().getId() + "]MqttGatewayClientTestDevice[" + gatewayDevice.getId().getId() + "]"; + } + private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException, ExecutionException { MqttClientConfig clientConfig = new MqttClientConfig(); + clientConfig.setOwnerId(getOwnerId()); clientConfig.setClientId("MQTT client from test"); clientConfig.setUsername(deviceCredentials.getCredentialsId()); - MqttClient mqttClient = MqttClient.create(clientConfig, listener); + MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor); mqttClient.connect("localhost", 1883).get(); return mqttClient; } @@ -421,9 +441,10 @@ public class MqttGatewayClientTest extends AbstractContainerTest { } @Override - public void onMessage(String topic, ByteBuf message) { + public ListenableFuture onMessage(String topic, ByteBuf message) { log.info("MQTT message [{}], topic [{}]", message.toString(StandardCharsets.UTF_8), topic); events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8))); + return Futures.immediateVoidFuture(); } } diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index 1c567588df..418184e732 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -41,7 +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}" + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" 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) diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index d81be2fece..aeadf2585d 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -35,6 +35,10 @@ + + org.thingsboard.common + util + io.netty netty-codec-mqtt diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java index fa88fa0254..59744ed194 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java @@ -16,6 +16,10 @@ package org.thingsboard.mqtt; import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; @@ -34,9 +38,13 @@ import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttSubAckMessage; import io.netty.handler.codec.mqtt.MqttUnsubAckMessage; import io.netty.util.CharsetUtil; +import io.netty.util.ReferenceCountUtil; import io.netty.util.concurrent.Promise; import lombok.extern.slf4j.Slf4j; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + @Slf4j final class MqttChannelHandler extends SimpleChannelInboundHandler { @@ -117,27 +125,48 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler super.channelInactive(ctx); } - private void invokeHandlersForIncomingPublish(MqttPublishMessage message) { - boolean handlerInvoked = false; - for (MqttSubscription subscription : ImmutableSet.copyOf(this.client.getSubscriptions().values())) { - if (subscription.matches(message.variableHeader().topicName())) { - if (subscription.isOnce() && subscription.isCalled()) { - continue; - } - message.payload().markReaderIndex(); - subscription.setCalled(true); - subscription.getHandler().onMessage(message.variableHeader().topicName(), message.payload()); - if (subscription.isOnce()) { - this.client.off(subscription.getTopic(), subscription.getHandler()); + ListenableFuture invokeHandlersForIncomingPublish(MqttPublishMessage message) { + var future = Futures.immediateVoidFuture(); + var handlerInvoked = new AtomicBoolean(); + try { + for (MqttSubscription subscription : ImmutableSet.copyOf(this.client.getSubscriptions().values())) { + if (subscription.matches(message.variableHeader().topicName())) { + future = Futures.transform(future, x -> { + if (subscription.isOnce() && subscription.isCalled()) { + return null; + } + message.payload().markReaderIndex(); + subscription.setCalled(true); + subscription.getHandler().onMessage(message.variableHeader().topicName(), message.payload()); + if (subscription.isOnce()) { + this.client.off(subscription.getTopic(), subscription.getHandler()); + } + message.payload().resetReaderIndex(); + handlerInvoked.set(true); + return null; + }, client.getHandlerExecutor()); } - message.payload().resetReaderIndex(); - handlerInvoked = true; } + future = Futures.transform(future, x -> { + if (!handlerInvoked.get() && client.getDefaultHandler() != null) { + client.getDefaultHandler().onMessage(message.variableHeader().topicName(), message.payload()); + } + return null; + }, client.getHandlerExecutor()); + } finally { + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Void result) { + message.payload().release(); + } + + @Override + public void onFailure(Throwable t) { + message.payload().release(); + } + }, MoreExecutors.directExecutor()); } - if (!handlerInvoked && client.getDefaultHandler() != null) { - client.getDefaultHandler().onMessage(message.variableHeader().topicName(), message.payload()); - } - message.payload().release(); + return future; } private void handleConack(Channel channel, MqttConnAckMessage message) { @@ -204,11 +233,13 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler break; case AT_LEAST_ONCE: - invokeHandlersForIncomingPublish(message); + var future = invokeHandlersForIncomingPublish(message); if (message.variableHeader().packetId() != -1) { - MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(message.variableHeader().packetId()); - channel.writeAndFlush(new MqttPubAckMessage(fixedHeader, variableHeader)); + future.addListener(() -> { + MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(message.variableHeader().packetId()); + channel.writeAndFlush(new MqttPubAckMessage(fixedHeader, variableHeader)); + }, MoreExecutors.directExecutor()); } break; @@ -263,14 +294,20 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler } private void handlePubrel(Channel channel, MqttMessage message) { + var future = Futures.immediateVoidFuture(); if (this.client.getQos2PendingIncomingPublishes().containsKey(((MqttMessageIdVariableHeader) message.variableHeader()).messageId())) { MqttIncomingQos2Publish incomingQos2Publish = this.client.getQos2PendingIncomingPublishes().get(((MqttMessageIdVariableHeader) message.variableHeader()).messageId()); - this.invokeHandlersForIncomingPublish(incomingQos2Publish.getIncomingPublish()); - this.client.getQos2PendingIncomingPublishes().remove(incomingQos2Publish.getIncomingPublish().variableHeader().packetId()); + future = invokeHandlersForIncomingPublish(incomingQos2Publish.getIncomingPublish()); + future = Futures.transform(future, x -> { + this.client.getQos2PendingIncomingPublishes().remove(incomingQos2Publish.getIncomingPublish().variableHeader().packetId()); + return null; + }, MoreExecutors.directExecutor()); } - MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(((MqttMessageIdVariableHeader) message.variableHeader()).messageId()); - channel.writeAndFlush(new MqttMessage(fixedHeader, variableHeader)); + future.addListener(() -> { + MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(((MqttMessageIdVariableHeader) message.variableHeader()).messageId()); + channel.writeAndFlush(new MqttMessage(fixedHeader, variableHeader)); + }, MoreExecutors.directExecutor()); } private void handlePubcomp(MqttMessage message) { @@ -281,4 +318,21 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler pendingPublish.getPayload().release(); pendingPublish.onPubcompReceived(); } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + try { + if (cause instanceof IOException) { + if (log.isDebugEnabled()) { + log.debug("[{}] IOException: ", client.getClientConfig().getOwnerId(), cause); + } else { + log.info("[{}] IOException: {}", client.getClientConfig().getOwnerId(), cause.getMessage()); + } + } else { + log.warn("[{}] exceptionCaught", client.getClientConfig().getOwnerId(), cause); + } + } finally { + ReferenceCountUtil.release(cause); + } + } } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java index 2fe179de31..536a76119f 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java @@ -21,6 +21,7 @@ import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.util.concurrent.Future; +import org.thingsboard.common.util.ListeningExecutor; public interface MqttClient { @@ -71,6 +72,8 @@ public interface MqttClient { */ void setEventLoop(EventLoopGroup eventLoop); + ListeningExecutor getHandlerExecutor(); + /** * Subscribe on the given topic. When a message is received, MqttClient will invoke the {@link MqttHandler#onMessage(String, ByteBuf)} function of the given handler * @@ -180,8 +183,8 @@ public interface MqttClient { * @param config The config object to use while looking for settings * @param defaultHandler The handler for incoming messages that do not match any topic subscriptions */ - static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler){ - return new MqttClientImpl(config, defaultHandler); + static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor){ + return new MqttClientImpl(config, defaultHandler, handlerExecutor); } /** diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java index 1e20a842c3..10cee5d5fc 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java @@ -19,6 +19,8 @@ import io.netty.channel.Channel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.mqtt.MqttVersion; import io.netty.handler.ssl.SslContext; +import lombok.Getter; +import lombok.Setter; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -26,10 +28,12 @@ import java.util.Random; @SuppressWarnings({"WeakerAccess", "unused"}) public final class MqttClientConfig { - private final SslContext sslContext; private final String randomClientId; + @Getter + @Setter + private String ownerId; // [TenantId][IntegrationId] or [TenantId][RuleNodeId] for exceptions logging purposes private String clientId; private int timeoutSeconds = 60; private MqttVersion protocolVersion = MqttVersion.MQTT_3_1; diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java index f38a790be7..63d65a1cc2 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java @@ -46,6 +46,7 @@ import io.netty.util.concurrent.DefaultPromise; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.Promise; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.ListeningExecutor; import java.util.Collections; import java.util.HashSet; @@ -88,13 +89,13 @@ final class MqttClientImpl implements MqttClient { private int port; private MqttClientCallback callback; + private final ListeningExecutor handlerExecutor; /** * Construct the MqttClientImpl with default config */ - public MqttClientImpl(MqttHandler defaultHandler) { - this.clientConfig = new MqttClientConfig(); - this.defaultHandler = defaultHandler; + public MqttClientImpl(MqttHandler defaultHandler, ListeningExecutor handlerExecutor) { + this(new MqttClientConfig(), defaultHandler, handlerExecutor); } /** @@ -103,9 +104,10 @@ final class MqttClientImpl implements MqttClient { * * @param clientConfig The config object to use while looking for settings */ - public MqttClientImpl(MqttClientConfig clientConfig, MqttHandler defaultHandler) { + public MqttClientImpl(MqttClientConfig clientConfig, MqttHandler defaultHandler, ListeningExecutor handlerExecutor) { this.clientConfig = clientConfig; this.defaultHandler = defaultHandler; + this.handlerExecutor = handlerExecutor; } /** @@ -227,6 +229,11 @@ final class MqttClientImpl implements MqttClient { this.eventLoop = eventLoop; } + @Override + public ListeningExecutor getHandlerExecutor() { + return this.handlerExecutor; + } + /** * Subscribe on the given topic. When a message is received, MqttClient will invoke the {@link MqttHandler#onMessage(String, ByteBuf)} function of the given handler * diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttHandler.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttHandler.java index 0ec03ff04b..21c07a17cd 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttHandler.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttHandler.java @@ -15,9 +15,10 @@ */ package org.thingsboard.mqtt; +import com.google.common.util.concurrent.ListenableFuture; import io.netty.buffer.ByteBuf; public interface MqttHandler { - void onMessage(String topic, ByteBuf payload); + ListenableFuture onMessage(String topic, ByteBuf payload); } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java index 6c4abb4c5c..c4bc9e38c1 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java @@ -25,7 +25,7 @@ final class MqttSubscription { private final boolean once; - private boolean called; + private volatile boolean called; MqttSubscription(String topic, MqttHandler handler, boolean once) { if (topic == null) { diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java index cb1b6b81fe..04c2be1740 100644 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java @@ -26,6 +26,7 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.thingsboard.common.util.AbstractListeningExecutor; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; import org.thingsboard.mqtt.MqttConnectResult; @@ -49,8 +50,18 @@ public class MqttIntegrationTest { MqttClient mqttClient; + AbstractListeningExecutor handlerExecutor; + @Before public void init() throws Exception { + this.handlerExecutor = new AbstractListeningExecutor() { + @Override + protected int getThreadPollSize() { + return 4; + } + }; + handlerExecutor.init(); + this.eventLoopGroup = new NioEventLoopGroup(); this.mqttServer = new MqttServer(); @@ -68,6 +79,9 @@ public class MqttIntegrationTest { if (this.eventLoopGroup != null) { this.eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS); } + if (this.handlerExecutor != null) { + this.handlerExecutor.destroy(); + } } @Test @@ -108,9 +122,10 @@ public class MqttIntegrationTest { private MqttClient initClient() throws Exception { MqttClientConfig config = new MqttClientConfig(); + config.setOwnerId("MqttIntegrationTest"); config.setTimeoutSeconds(KEEPALIVE_TIMEOUT_SECONDS); config.setReconnectDelay(RECONNECT_DELAY_SECONDS); - MqttClient client = MqttClient.create(config, null); + MqttClient client = MqttClient.create(config, null, handlerExecutor); client.setEventLoop(this.eventLoopGroup); Future connectFuture = client.connect(MQTT_HOST, this.mqttServer.getMqttPort()); diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 51eb446d70..8c8829a727 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -2364,7 +2364,8 @@ public class RestClient implements Closeable { boolean deleteAllDataForKeys, Long startTs, Long endTs, - boolean rewriteLatestIfDeleted) { + boolean rewriteLatestIfDeleted, + boolean deleteLatest) { Map params = new HashMap<>(); params.put("entityType", entityId.getEntityType().name()); params.put("entityId", entityId.getId().toString()); @@ -2373,17 +2374,34 @@ public class RestClient implements Closeable { params.put("startTs", startTs.toString()); params.put("endTs", endTs.toString()); params.put("rewriteLatestIfDeleted", String.valueOf(rewriteLatestIfDeleted)); + params.put("deleteLatest", String.valueOf(deleteLatest)); return restTemplate .exchange( - baseURL + "/api/plugins/telemetry/{entityType}/{entityId}/timeseries/delete?keys={keys}&deleteAllDataForKeys={deleteAllDataForKeys}&startTs={startTs}&endTs={endTs}&rewriteLatestIfDeleted={rewriteLatestIfDeleted}", + baseURL + "/api/plugins/telemetry/{entityType}/{entityId}/timeseries/delete?keys={keys}&deleteAllDataForKeys={deleteAllDataForKeys}&startTs={startTs}&endTs={endTs}&rewriteLatestIfDeleted={rewriteLatestIfDeleted}&deleteLatest={deleteLatest}", HttpMethod.DELETE, HttpEntity.EMPTY, Object.class, params) .getStatusCode() .is2xxSuccessful(); + } + public boolean deleteEntityLatestTimeseries(EntityId entityId, List keys) { + Map params = new HashMap<>(); + params.put("entityType", entityId.getEntityType().name()); + params.put("entityId", entityId.getId().toString()); + params.put("keys", listToString(keys)); + + return restTemplate + .exchange( + baseURL + "/api/plugins/telemetry/{entityType}/{entityId}/timeseries/latest/delete?keys={keys}", + HttpMethod.DELETE, + HttpEntity.EMPTY, + Object.class, + params) + .getStatusCode() + .is2xxSuccessful(); } public boolean deleteEntityAttributes(DeviceId deviceId, String scope, List keys) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java index 2121e0c5fa..d5b06b4537 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java @@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; -import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -50,7 +49,7 @@ public class TbMsgTypeSwitchNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - ctx.tellNext(msg, TbMsgType.getRuleNodeConnectionOrElseOther(msg.getType())); + ctx.tellNext(msg, msg.getInternalType().getRuleNodeConnection()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java index 1636898b8c..e1a3523cf0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java @@ -32,9 +32,10 @@ public class TbMathNodeConfiguration implements NodeConfiguration connectFuture = client.connect(this.mqttNodeConfiguration.getHost(), this.mqttNodeConfiguration.getPort()); MqttConnectResult result; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/MultipleTbMsgsCallbackWrapper.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/MultipleTbMsgsCallbackWrapper.java index ee63201fde..142aa8c085 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/MultipleTbMsgsCallbackWrapper.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/MultipleTbMsgsCallbackWrapper.java @@ -39,7 +39,7 @@ public class MultipleTbMsgsCallbackWrapper implements TbMsgCallbackWrapper { @Override public void onFailure(Throwable t) { - callback.onFailure(new RuleEngineException(t.getMessage())); + callback.onFailure(new RuleEngineException(t.getMessage(), t)); } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNodeTest.java index c4fc8cd76d..603c23cd05 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNodeTest.java @@ -81,9 +81,10 @@ class TbMsgTypeSwitchNodeTest { var msg = resultMsgs.get(i); assertThat(msg).isNotNull(); assertThat(msg.getType()).isNotNull(); + assertThat(msg.getType()).isEqualTo(msg.getInternalType().name()); assertThat(msg).isSameAs(tbMsgList.get(i)); assertThat(resultNodeConnections.get(i)) - .isEqualTo(TbMsgType.getRuleNodeConnectionOrElseOther(msg.getType())); + .isEqualTo(msg.getInternalType().getRuleNodeConnection()); } } diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 1f8861ced9..bbded8aec9 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -41,7 +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}" + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" cache: type: "${CACHE_TYPE:redis}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 7c5103cfac..396d95e63c 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -68,7 +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}" + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" cache: type: "${CACHE_TYPE:redis}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 4ab59aec01..7c0cd950ff 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -41,7 +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}" + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" cache: type: "${CACHE_TYPE:redis}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index a103edf1f4..e6f2b0af60 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -41,7 +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}" + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" cache: type: "${CACHE_TYPE:redis}" diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 44a86dc6dd..3fc11bbdfe 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -41,7 +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}" + recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" cache: type: "${CACHE_TYPE:redis}" diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index f1af3b745a..8a838b4130 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -385,10 +385,10 @@ export class AuthService { } else if (authPayload.authUser) { authPayload.authUser.authority = Authority.ANONYMOUS; } - if (authPayload.authUser.isPublic) { + if (authPayload.authUser?.isPublic) { authPayload.forceFullscreen = true; } - if (authPayload.authUser.isPublic) { + if (authPayload.authUser?.isPublic) { this.loadSystemParams().subscribe( (sysParams) => { authPayload = {...authPayload, ...sysParams}; @@ -399,10 +399,10 @@ export class AuthService { loadUserSubject.error(err); } ); - } else if (authPayload.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) { + } else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN) { loadUserSubject.next(authPayload); loadUserSubject.complete(); - } else if (authPayload.authUser.userId) { + } else if (authPayload.authUser?.userId) { this.userService.getUser(authPayload.authUser.userId).subscribe( (user) => { authPayload.userDetails = user; diff --git a/ui-ngx/src/app/core/http/alarm.service.ts b/ui-ngx/src/app/core/http/alarm.service.ts index e03e9ab783..f0ce187341 100644 --- a/ui-ngx/src/app/core/http/alarm.service.ts +++ b/ui-ngx/src/app/core/http/alarm.service.ts @@ -52,12 +52,12 @@ export class AlarmService { return this.http.post('/api/alarm', alarm, defaultHttpOptionsFromConfig(config)); } - public ackAlarm(alarmId: string, config?: RequestConfig): Observable { - return this.http.post(`/api/alarm/${alarmId}/ack`, null, defaultHttpOptionsFromConfig(config)); + public ackAlarm(alarmId: string, config?: RequestConfig): Observable { + return this.http.post(`/api/alarm/${alarmId}/ack`, null, defaultHttpOptionsFromConfig(config)); } - public clearAlarm(alarmId: string, config?: RequestConfig): Observable { - return this.http.post(`/api/alarm/${alarmId}/clear`, null, defaultHttpOptionsFromConfig(config)); + public clearAlarm(alarmId: string, config?: RequestConfig): Observable { + return this.http.post(`/api/alarm/${alarmId}/clear`, null, defaultHttpOptionsFromConfig(config)); } public assignAlarm(alarmId: string, assigneeId: string, config?: RequestConfig): Observable { @@ -68,8 +68,8 @@ export class AlarmService { return this.http.delete(`/api/alarm/${alarmId}/assign`, defaultHttpOptionsFromConfig(config)); } - public deleteAlarm(alarmId: string, config?: RequestConfig): Observable { - return this.http.delete(`/api/alarm/${alarmId}`, defaultHttpOptionsFromConfig(config)); + public deleteAlarm(alarmId: string, config?: RequestConfig): Observable { + return this.http.delete(`/api/alarm/${alarmId}`, defaultHttpOptionsFromConfig(config)); } public getAlarms(query: AlarmQuery, diff --git a/ui-ngx/src/app/core/http/attribute.service.ts b/ui-ngx/src/app/core/http/attribute.service.ts index 67132d8983..9022a47eef 100644 --- a/ui-ngx/src/app/core/http/attribute.service.ts +++ b/ui-ngx/src/app/core/http/attribute.service.ts @@ -50,10 +50,19 @@ export class AttributeService { } public deleteEntityTimeseries(entityId: EntityId, timeseries: Array, deleteAllDataForKeys = false, - startTs?: number, endTs?: number, config?: RequestConfig): Observable { + startTs?: number, endTs?: number, rewriteLatestIfDeleted = false, deleteLatest = true, + config?: RequestConfig): Observable { const keys = timeseries.map(attribute => encodeURIComponent(attribute.key)).join(','); - let url = `/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/timeseries/delete` + - `?keys=${keys}&deleteAllDataForKeys=${deleteAllDataForKeys}`; + let url = `/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/timeseries/delete?keys=${keys}`; + if (isDefinedAndNotNull(deleteAllDataForKeys)) { + url += `&deleteAllDataForKeys=${deleteAllDataForKeys}`; + } + if (isDefinedAndNotNull(rewriteLatestIfDeleted)) { + url += `&rewriteLatestIfDeleted=${rewriteLatestIfDeleted}`; + } + if (isDefinedAndNotNull(deleteLatest)) { + url += `&deleteLatest=${deleteLatest}`; + } if (isDefinedAndNotNull(startTs)) { url += `&startTs=${startTs}`; } @@ -103,7 +112,8 @@ export class AttributeService { }); let deleteEntityTimeseriesObservable: Observable; if (deleteTimeseries.length) { - deleteEntityTimeseriesObservable = this.deleteEntityTimeseries(entityId, deleteTimeseries, true, null, null, config); + deleteEntityTimeseriesObservable = this.deleteEntityTimeseries(entityId, deleteTimeseries, true, + null, null, false, true, config); } else { deleteEntityTimeseriesObservable = of(null); } diff --git a/ui-ngx/src/app/core/http/notification.service.ts b/ui-ngx/src/app/core/http/notification.service.ts index ec64621aeb..20e10c91e2 100644 --- a/ui-ngx/src/app/core/http/notification.service.ts +++ b/ui-ngx/src/app/core/http/notification.service.ts @@ -31,6 +31,7 @@ import { NotificationTarget, NotificationTemplate, NotificationType, + NotificationUserSettings, SlackChanelType, SlackConversation } from '@shared/models/notification.models'; @@ -174,4 +175,12 @@ export class NotificationService { } return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } + + public getNotificationUserSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/notification/settings/user`, defaultHttpOptionsFromConfig(config)); + } + + public saveNotificationUserSettings(settings: NotificationUserSettings, config?: RequestConfig): Observable { + return this.http.post('/api/notification/settings/user', settings, defaultHttpOptionsFromConfig(config)); + } } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 2a2f664c3a..8d48c5f038 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -130,7 +130,7 @@ export function isLiteralObject(value: any) { return (!!value) && (value.constructor === Object); } -export function formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined { +export const formatValue = (value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined => { if (isDefinedAndNotNull(value) && isNumeric(value) && (isDefinedAndNotNull(dec) || isDefinedAndNotNull(units) || Number(value).toString() === value)) { let formatted: string | number = Number(value); @@ -150,6 +150,16 @@ export function formatValue(value: any, dec?: number, units?: string, showZeroDe } } +export const formatNumberValue = (value: any, dec?: number): number | undefined => { + if (isDefinedAndNotNull(value) && isNumeric(value)) { + let formatted: string | number = Number(value); + if (isDefinedAndNotNull(dec)) { + formatted = formatted.toFixed(dec); + } + return Number(formatted); + } +} + export function objectValues(obj: any): any[] { return Object.keys(obj).map(e => obj[e]); } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts index 28e64738a8..15e3fb1952 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts @@ -26,17 +26,17 @@ import { DatePipe } from '@angular/common'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog } from '@angular/material/dialog'; import { TimePageLink } from '@shared/models/page/page-link'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; import { AlarmInfo, - AlarmQuery, AlarmQueryV2, AlarmSearchStatus, alarmSeverityColors, alarmSeverityTranslations, AlarmsMode, + AlarmStatus, alarmStatusTranslations } from '@app/shared/models/alarm.models'; import { AlarmService } from '@app/core/http/alarm.service'; @@ -46,7 +46,7 @@ import { AlarmDetailsDialogComponent, AlarmDetailsDialogData } from '@home/components/alarm/alarm-details-dialog.component'; -import { DAY, forAllTimeInterval, historyInterval } from '@shared/models/time/time.models'; +import { forAllTimeInterval } from '@shared/models/time/time.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; @@ -93,7 +93,7 @@ export class AlarmTableConfig extends EntityTableConfig this.pageMode = pageMode; this.defaultTimewindowInterval = forAllTimeInterval(); this.detailsPanelEnabled = false; - this.selectionEnabled = false; + this.selectionEnabled = true; this.searchEnabled = true; this.addEnabled = false; this.entitiesDeleteEnabled = false; @@ -155,6 +155,27 @@ export class AlarmTableConfig extends EntityTableConfig onAction: ($event, entity) => this.showAlarmDetails(entity) } ); + + this.groupActionDescriptors.push( + { + name: this.translate.instant('alarm.acknowledge'), + icon: 'done', + isEnabled: true, + onAction: ($event, entities) => this.ackAlarms($event, entities) + }, + { + name: this.translate.instant('alarm.clear'), + icon: 'clear', + isEnabled: true, + onAction: ($event, entities) => this.clearAlarms($event, entities) + }, + { + name: this.translate.instant('alarm.delete'), + icon: 'delete', + isEnabled: true, + onAction: ($event, entities) => this.deleteAlarms($event, entities) + } + ) } fetchAlarms(pageLink: TimePageLink): Observable> { @@ -294,4 +315,103 @@ export class AlarmTableConfig extends EntityTableConfig }); } + ackAlarms($event: Event, alarms: Array) { + if ($event) { + $event.stopPropagation(); + } + const unacknowledgedAlarms = alarms.filter(alarm => { + return alarm.status === AlarmStatus.CLEARED_UNACK || alarm.status === AlarmStatus.ACTIVE_UNACK; + }) + let title = ''; + let content = ''; + if (!unacknowledgedAlarms.length) { + title = this.translate.instant('alarm.selected-alarms', {count: alarms.length}); + content = this.translate.instant('alarm.selected-alarms-are-acknowledged'); + this.dialogService.alert( + title, + content).subscribe(); + } else { + title = this.translate.instant('alarm.aknowledge-alarms-title', {count: unacknowledgedAlarms.length}); + content = this.translate.instant('alarm.aknowledge-alarms-text', {count: unacknowledgedAlarms.length}); + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + for (const alarm of unacknowledgedAlarms) { + tasks.push(this.alarmService.ackAlarm(alarm.id.id)); + } + forkJoin(tasks).subscribe(() => { + this.updateData(); + }); + } + }); + } + } + + clearAlarms($event: Event, alarms: Array) { + if ($event) { + $event.stopPropagation(); + } + const activeAlarms = alarms.filter(alarm => { + return alarm.status === AlarmStatus.ACTIVE_ACK || alarm.status === AlarmStatus.ACTIVE_UNACK; + }) + let title = ''; + let content = ''; + if (!activeAlarms.length) { + title = this.translate.instant('alarm.selected-alarms', {count: alarms.length}); + content = this.translate.instant('alarm.selected-alarms-are-cleared'); + this.dialogService.alert( + title, + content + ).subscribe(); + } else { + title = this.translate.instant('alarm.clear-alarms-title', {count: activeAlarms.length}); + content = this.translate.instant('alarm.clear-alarms-text', {count: activeAlarms.length}); + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + for (const alarm of activeAlarms) { + tasks.push(this.alarmService.clearAlarm(alarm.id.id)); + } + forkJoin(tasks).subscribe(() => { + this.updateData(); + }); + } + }); + } + } + + deleteAlarms($event: Event, alarms: Array) { + if ($event) { + $event.stopPropagation(); + } + const title = this.translate.instant('alarm.delete-alarms-title', {count: alarms.length}); + const content = this.translate.instant('alarm.delete-alarms-text', {count: alarms.length}); + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + for (const alarm of alarms) { + tasks.push(this.alarmService.deleteAlarm(alarm.id.id)); + } + forkJoin(tasks).subscribe(() => { + this.updateData(); + }); + } + }); + } + } diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html index 9f094698f5..61b76b9f0d 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html @@ -85,12 +85,12 @@ 'attribute.selected-telemetry' : 'attribute.selected-attributes') | translate:{count: dataSource.selection.selected.length} }} - + (click)="deleteTelemetry($event)"> delete edit + + delete + diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts index 784c6b155a..beb80ecf7e 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -49,6 +49,7 @@ import { LatestTelemetry, TelemetryType, telemetryTypeTranslations, + TimeseriesDeleteStrategy, toTelemetryType } from '@shared/models/telemetry/telemetry.models'; import { AttributeDatasource } from '@home/models/datasource/attribute-datasource'; @@ -82,10 +83,15 @@ import { AddWidgetToDashboardDialogComponent, AddWidgetToDashboardDialogData } from '@home/components/attribute/add-widget-to-dashboard-dialog.component'; -import { deepClone } from '@core/utils'; +import { deepClone, isUndefinedOrNull } from '@core/utils'; import { Filters } from '@shared/models/query/query.models'; import { hidePageSizePixelValue } from '@shared/models/constants'; import { ResizeObserver } from '@juggle/resize-observer'; +import { + DELETE_TIMESERIES_PANEL_DATA, + DeleteTimeseriesPanelComponent, + DeleteTimeseriesPanelData +} from '@home/components/attribute/delete-timeseries-panel.component'; @Component({ @@ -379,6 +385,82 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI }); } + deleteTimeseries($event: Event, telemetry?: AttributeData) { + if ($event) { + $event.stopPropagation(); + } + const isMultipleDeletion = isUndefinedOrNull(telemetry) && this.dataSource.selection.selected.length > 1; + const target = $event.target || $event.srcElement || $event.currentTarget; + const config = new OverlayConfig({ + panelClass: 'tb-filter-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + maxWidth: 488, + width: '100%' + }); + const connectedPosition: ConnectedPosition = { + originX: 'start', + originY: 'top', + overlayX: 'end', + overlayY: 'top' + }; + config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) + .withPositions([connectedPosition]); + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const providers: StaticProvider[] = [ + { + provide: DELETE_TIMESERIES_PANEL_DATA, + useValue: { + isMultipleDeletion + } as DeleteTimeseriesPanelData + }, + { + provide: OverlayRef, + useValue: overlayRef + } + ]; + const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); + const componentRef = overlayRef.attach(new ComponentPortal(DeleteTimeseriesPanelComponent, + this.viewContainerRef, injector)); + componentRef.onDestroy(() => { + if (componentRef.instance.result !== null) { + const result = componentRef.instance.result; + const deleteTimeseries = telemetry ? [telemetry]: this.dataSource.selection.selected; + let deleteAllDataForKeys = false; + let rewriteLatestIfDeleted = false; + let startTs = null; + let endTs = null; + let deleteLatest = true; + switch (result.strategy) { + case TimeseriesDeleteStrategy.DELETE_ALL_DATA: + deleteAllDataForKeys = true; + break; + case TimeseriesDeleteStrategy.DELETE_ALL_DATA_EXCEPT_LATEST_VALUE: + deleteAllDataForKeys = true; + deleteLatest = false; + break; + case TimeseriesDeleteStrategy.DELETE_LATEST_VALUE: + rewriteLatestIfDeleted = result.rewriteLatest; + startTs = deleteTimeseries[0].lastUpdateTs; + endTs = startTs + 1; + break; + case TimeseriesDeleteStrategy.DELETE_ALL_DATA_FOR_TIME_PERIOD: + startTs = result.startDateTime.getTime(); + endTs = result.endDateTime.getTime(); + rewriteLatestIfDeleted = result.rewriteLatest; + break; + } + this.attributeService.deleteEntityTimeseries(this.entityIdValue, deleteTimeseries, deleteAllDataForKeys, + startTs, endTs, rewriteLatestIfDeleted, deleteLatest) + .subscribe(() => this.reloadAttributes()); + } + }); + } + deleteAttributes($event: Event) { if ($event) { $event.stopPropagation(); @@ -403,6 +485,14 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI } } + deleteTelemetry($event: Event) { + if (this.attributeScope === this.latestTelemetryTypes.LATEST_TELEMETRY) { + this.deleteTimeseries($event); + } else { + this.deleteAttributes($event); + } + } + enterWidgetMode() { this.mode = 'widget'; this.widgetsList = []; diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html new file mode 100644 index 0000000000..5778fc6805 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html @@ -0,0 +1,68 @@ + + + + {{ "attribute.delete-timeseries.delete-strategy" | translate }} + + + close + + + + + attribute.delete-timeseries.strategy + + + {{ strategiesTranslationsMap.get(strategy) | translate }} + + + + + + attribute.delete-timeseries.start-time + + + + + + attribute.delete-timeseries.ends-on + + + + + + + {{ "attribute.delete-timeseries.rewrite-latest-value" | translate }} + + + + + + {{ 'action.cancel' | translate }} + + + {{ 'action.apply' | translate }} + + diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss new file mode 100644 index 0000000000..c9a0e527f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss @@ -0,0 +1,44 @@ +/** + * 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'; + +:host { + width: 100%; + + .mat-toolbar { + background: none; + } + + .tb-form-settings { + flex-direction: column; + gap: 16px; + padding-top: 0; + } + + .tb-select-interval { + display: flex; + flex-direction: row; + gap: 16px; + @media #{$mat-xs} { + flex-direction: column; + gap: 0; + } + } + + .tb-slide-toggle { + margin-bottom: 8px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts new file mode 100644 index 0000000000..94c66b3734 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts @@ -0,0 +1,151 @@ +/// +/// 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, Inject, InjectionToken, OnDestroy, OnInit } from '@angular/core'; +import { OverlayRef } from '@angular/cdk/overlay'; +import { + TimeseriesDeleteStrategy, + timeseriesDeleteStrategyTranslations +} from '@shared/models/telemetry/telemetry.models'; +import { MINUTE } from '@shared/models/time/time.models'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +export const DELETE_TIMESERIES_PANEL_DATA = new InjectionToken('DeleteTimeseriesPanelData'); + +export interface DeleteTimeseriesPanelData { + isMultipleDeletion: boolean; +} + +export interface DeleteTimeseriesPanelResult { + strategy: TimeseriesDeleteStrategy; + startDateTime: Date; + endDateTime: Date; + rewriteLatest: boolean; +} + +@Component({ + selector: 'tb-delete-timeseries-panel', + templateUrl: './delete-timeseries-panel.component.html', + styleUrls: ['./delete-timeseries-panel.component.scss'] +}) +export class DeleteTimeseriesPanelComponent implements OnInit, OnDestroy { + + deleteTimeseriesFormGroup: FormGroup; + + result: DeleteTimeseriesPanelResult = null; + + strategiesTranslationsMap = timeseriesDeleteStrategyTranslations; + + private multipleDeletionStrategies = new Set([ + TimeseriesDeleteStrategy.DELETE_ALL_DATA, + TimeseriesDeleteStrategy.DELETE_ALL_DATA_EXCEPT_LATEST_VALUE + ]); + + private destroy$ = new Subject(); + + constructor(@Inject(DELETE_TIMESERIES_PANEL_DATA) private data: DeleteTimeseriesPanelData, + private overlayRef: OverlayRef, + private fb: FormBuilder) { } + + ngOnInit(): void { + const today = new Date(); + if (this.data.isMultipleDeletion) { + this.strategiesTranslationsMap = new Map(Array.from(this.strategiesTranslationsMap) + .filter(([strategy]) => this.multipleDeletionStrategies.has(strategy))) + } + this.deleteTimeseriesFormGroup = this.fb.group({ + strategy: [TimeseriesDeleteStrategy.DELETE_ALL_DATA], + startDateTime: [ + { value: new Date(today.getFullYear(), today.getMonth() - 1, today.getDate()), disabled: true }, + Validators.required + ], + endDateTime: [{ value: today, disabled: true }, Validators.required], + rewriteLatest: [true] + }) + + this.deleteTimeseriesFormGroup.get('strategy').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value === TimeseriesDeleteStrategy.DELETE_ALL_DATA_FOR_TIME_PERIOD) { + this.deleteTimeseriesFormGroup.get('startDateTime').enable({onlySelf: true, emitEvent: false}); + this.deleteTimeseriesFormGroup.get('endDateTime').enable({onlySelf: true, emitEvent: false}); + } else { + this.deleteTimeseriesFormGroup.get('startDateTime').disable({onlySelf: true, emitEvent: false}); + this.deleteTimeseriesFormGroup.get('endDateTime').disable({onlySelf: true, emitEvent: false}); + } + }) + this.deleteTimeseriesFormGroup.get('startDateTime').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => this.onStartDateTimeChange(value)); + this.deleteTimeseriesFormGroup.get('endDateTime').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => this.onEndDateTimeChange(value)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + delete(): void { + if (this.deleteTimeseriesFormGroup.valid) { + this.result = this.deleteTimeseriesFormGroup.value; + this.overlayRef.dispose(); + } else { + this.deleteTimeseriesFormGroup.markAllAsTouched(); + } + } + + cancel(): void { + this.overlayRef.dispose(); + } + + isPeriodStrategy(): boolean { + return this.deleteTimeseriesFormGroup.get('strategy').value === TimeseriesDeleteStrategy.DELETE_ALL_DATA_FOR_TIME_PERIOD; + } + + isDeleteLatestStrategy(): boolean { + return this.deleteTimeseriesFormGroup.get('strategy').value === TimeseriesDeleteStrategy.DELETE_LATEST_VALUE; + } + + private onStartDateTimeChange(newStartDateTime: Date) { + if (newStartDateTime) { + const endDateTimeTs = this.deleteTimeseriesFormGroup.get('endDateTime').value.getTime(); + if (newStartDateTime.getTime() >= endDateTimeTs) { + this.deleteTimeseriesFormGroup.get('startDateTime') + .patchValue(new Date(endDateTimeTs - MINUTE), {onlySelf: true, emitEvent: false}); + } else { + this.deleteTimeseriesFormGroup.get('startDateTime') + .patchValue(newStartDateTime, {onlySelf: true, emitEvent: false}); + } + } + } + + private onEndDateTimeChange(newEndDateTime: Date) { + if (newEndDateTime) { + const startDateTimeTs = this.deleteTimeseriesFormGroup.get('startDateTime').value.getTime(); + if (newEndDateTime.getTime() <= startDateTimeTs) { + this.deleteTimeseriesFormGroup.get('endDateTime') + .patchValue(new Date(startDateTimeTs + MINUTE), {onlySelf: true, emitEvent: false}); + } else { + this.deleteTimeseriesFormGroup.get('endDateTime') + .patchValue(newEndDateTime, {onlySelf: true, emitEvent: false}); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index d574a3593a..79987e2c4d 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -40,6 +40,7 @@ import { EventContentDialogData } from '@home/components/event/event-content-dialog.component'; import { isEqual, sortObjectKeys } from '@core/utils'; +import { historyInterval, MINUTE } from '@shared/models/time/time.models'; import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { ChangeDetectorRef, EventEmitter, Injector, StaticProvider, ViewContainerRef } from '@angular/core'; import { ComponentPortal } from '@angular/cdk/portal'; @@ -92,6 +93,7 @@ export class EventTableConfig extends EntityTableConfig { this.loadDataOnInit = false; this.tableTitle = ''; this.useTimePageLink = true; + this.defaultTimewindowInterval = historyInterval(MINUTE * 15); this.detailsPanelEnabled = false; this.selectionEnabled = false; this.searchEnabled = false; @@ -179,7 +181,7 @@ export class EventTableConfig extends EntityTableConfig { updateColumns(updateTableColumns: boolean = false): void { this.columns = []; this.columns.push( - new DateEntityTableColumn('createdTime', 'event.event-time', this.datePipe, '120px'), + new DateEntityTableColumn('createdTime', 'event.event-time', this.datePipe, '120px', 'yyyy-MM-dd HH:mm:ss.SSS'), new EntityTableColumn('server', 'event.server', '100px', (entity) => entity.body.server, entity => ({}), false)); switch (this.eventType) { diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index a6e2cc03dc..a18f785b35 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -177,6 +177,7 @@ import { } from '@home/components/widget/action/manage-widget-actions-dialog.component'; import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module'; import { BasicWidgetConfigModule } from '@home/components/widget/config/basic/basic-widget-config.module'; +import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delete-timeseries-panel.component'; @NgModule({ declarations: @@ -205,6 +206,7 @@ import { BasicWidgetConfigModule } from '@home/components/widget/config/basic/ba AttributeTableComponent, AddAttributeDialogComponent, EditAttributeValuePanelComponent, + DeleteTimeseriesPanelComponent, AliasesEntitySelectPanelComponent, AliasesEntitySelectComponent, AliasesEntityAutocompleteComponent, diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html index b052a1816c..57c5ecf621 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html @@ -15,49 +15,47 @@ limitations under the License. --> - - - - + + + {{ 'relation.type' | translate }} + {{ 'entity.entity-types' | translate }} + + + + - - - - - - - - + + + + + - close + delete + + + + relation.any-relation - - relation.any-relation + + + {{ 'filter.add' | translate }} + - - {{ 'relation.add-relation-filter' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss index a2d4232f24..648076be4c 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss @@ -14,43 +14,15 @@ * limitations under the License. */ :host { - .tb-relation-filters { - max-width: calc(100vw - 48px); - margin-top: 2px; - overflow: hidden; - - .container{ - width: 100%; - } - - .map-label { - font-weight: 400; - font-size: 12px; - } - - .body { - max-height: 363px; - overflow: auto; - - .row { - padding-top: 5px; - - .input-block { - border: 1px solid #E0E0E0; - width: 100%; - border-radius: 6px; - padding: 24px; - align-items: center; - } - } - } + .flex-50 { + flex: 1 1 50%; + } - .any-filter{ - margin: 10px 0 20px; - } + .actions-header { + width: 40px + } - .add-button { - margin: 5px 0px 15px; - } + .entity-type-list { + display: flex; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 795d46984c..7c4168a786 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -40,6 +40,15 @@ import { import { ValueCardBasicConfigComponent } from '@home/components/widget/config/basic/cards/value-card-basic-config.component'; +import { + AggregatedValueCardBasicConfigComponent +} from '@home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component'; +import { + AggregatedDataKeyRowComponent +} from '@home/components/widget/config/basic/cards/aggregated-data-key-row.component'; +import { + AggregatedDataKeysPanelComponent +} from '@home/components/widget/config/basic/cards/aggregated-data-keys-panel.component'; @NgModule({ declarations: [ @@ -50,6 +59,9 @@ import { FlotBasicConfigComponent, AlarmsTableBasicConfigComponent, ValueCardBasicConfigComponent, + AggregatedValueCardBasicConfigComponent, + AggregatedDataKeyRowComponent, + AggregatedDataKeysPanelComponent, DataKeyRowComponent, DataKeysPanelComponent ], @@ -66,6 +78,9 @@ import { FlotBasicConfigComponent, AlarmsTableBasicConfigComponent, ValueCardBasicConfigComponent, + AggregatedValueCardBasicConfigComponent, + AggregatedDataKeyRowComponent, + AggregatedDataKeysPanelComponent, DataKeyRowComponent, DataKeysPanelComponent ] @@ -79,5 +94,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type + + + + + {{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} + + + + + + + + + + + + + + edit + + + + + + + + + + + + + + + + + + + + + + + + + + delete + + + + + + f() + + + + + + + + {{ (modelValue?.aggregationType || aggregationTypes.NONE) }} + ({{ 'datakey.latest-value' | translate }}) + + + ({{ 'datakey.delta' | translate }}:{{ (modelValue?.comparisonResultType === comparisonResultTypes.DELTA_PERCENT ? 'datakey.percent' : 'datakey.absolute') | translate }}) + ({{ 'datakey.delta-calculation-result-previous-value' | translate }}) + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss new file mode 100644 index 0000000000..e07b9fa9ad --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.scss @@ -0,0 +1,88 @@ +/** + * 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-aggregated-data-key-row { + .mat-mdc-form-field.tb-inline-field.tb-aggregation-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; + .mdc-evolution-chip-set .mdc-evolution-chip { + margin: 0; + } + input.mat-mdc-chip-input { + height: 32px; + margin-left: 0; + } + } + } + .mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip { + .tb-attribute-chip { + .tb-chip-labels { + background: transparent; + } + } + } + } + + .tb-position-field { + width: 132px; + min-width: 132px; + } + + .tb-aggregation-field { + flex: 1; + min-width: 150px; + } + + .tb-units-field, .tb-decimals-field, .tb-font-field, .tb-color-field { + display: flex; + flex-direction: row; + place-content: center; + align-items: center; + } + + .tb-units-field { + width: 80px; + min-width: 80px; + } + + .tb-decimals-field { + width: 60px; + min-width: 60px; + } + + .tb-font-field { + width: 40px; + min-width: 40px; + } + + .tb-color-field { + width: 40px; + min-width: 40px; + } + + .tb-units-field, .tb-decimals-field { + display: none; + @media #{$mat-gt-sm} { + display: block; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts new file mode 100644 index 0000000000..411a6a03b6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts @@ -0,0 +1,233 @@ +/// +/// 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 { + ChangeDetectorRef, + Component, + EventEmitter, + forwardRef, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { + ComparisonResultType, + DataKey, + DataKeyConfigMode, + DatasourceType, Widget, + widgetType +} from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AggregationType } from '@shared/models/time/time.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { TranslateService } from '@ngx-translate/core'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/config/data-key-config-dialog.component'; +import { deepClone, formatValue } from '@core/utils'; +import { + AggregatedValueCardKeyPosition, + aggregatedValueCardKeyPositionTranslations, + AggregatedValueCardKeySettings +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; + +@Component({ + selector: 'tb-aggregated-data-key-row', + templateUrl: './aggregated-data-key-row.component.html', + styleUrls: ['./aggregated-data-key-row.component.scss', '../../data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregatedDataKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class AggregatedDataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges { + + aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] = + Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]); + + aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations; + + dataKeyTypes = DataKeyType; + + aggregationTypes = AggregationType; + + comparisonResultTypes = ComparisonResultType; + + @Input() + disabled: boolean; + + @Input() + datasourceType: DatasourceType; + + @Input() + keyName: string; + + @Output() + keyRemoved = new EventEmitter(); + + keyRowFormGroup: UntypedFormGroup; + + modelValue: DataKey; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + get callbacks(): DataKeysCallbacks { + return this.widgetConfigComponent.widgetConfigCallbacks; + } + + get widget(): Widget { + return this.widgetConfigComponent.widget; + } + + get isEntityDatasource(): boolean { + return [DatasourceType.device, DatasourceType.entity].includes(this.datasourceType); + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef, + public translate: TranslateService, + public truncate: TruncatePipe, + private widgetConfigComponent: WidgetConfigComponent) { + } + + ngOnInit() { + this.keyRowFormGroup = this.fb.group({ + position: [null, []], + units: [null, []], + decimals: [null, []], + font: [null, []], + color: [null, []] + }); + this.keyRowFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['keyName'].includes(propName)) { + if (change.currentValue) { + this.modelValue.name = change.currentValue; + setTimeout(() => { + this.updateModel(); + }, 0); + } + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.keyRowFormGroup.disable({emitEvent: false}); + } else { + this.keyRowFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DataKey): void { + this.modelValue = value || {} as DataKey; + const settings: AggregatedValueCardKeySettings = (this.modelValue.settings || {}); + this.keyRowFormGroup.patchValue( + { + position: settings.position || AggregatedValueCardKeyPosition.center, + units: value?.units, + decimals: value?.decimals, + font: settings.font, + color: settings.color + }, {emitEvent: false} + ); + this.cd.markForCheck(); + } + + dataKeyHasPostprocessing(): boolean { + return !!this.modelValue?.postFuncBody; + } + + editKey() { + this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(this.modelValue), + dataKeyConfigMode: DataKeyConfigMode.general, + dataKeySettingsSchema: null, + dataKeySettingsDirective: null, + dashboard: null, + aliasController: null, + widget: this.widget, + widgetType: widgetType.latest, + deviceId: null, + entityAliasId: null, + showPostProcessing: true, + callbacks: this.callbacks, + hideDataKeyName: true, + hideDataKeyLabel: true, + hideDataKeyColor: true + } + }).afterClosed().subscribe((updatedDataKey) => { + if (updatedDataKey) { + this.modelValue = updatedDataKey; + this.keyRowFormGroup.get('units').patchValue(this.modelValue.units, {emitEvent: false}); + this.keyRowFormGroup.get('decimals').patchValue(this.modelValue.decimals, {emitEvent: false}); + this.updateModel(); + } + }); + } + + private updateModel() { + const value = this.keyRowFormGroup.value; + this.modelValue.settings = this.modelValue.settings || {}; + this.modelValue.settings.position = value.position; + this.modelValue.settings.font = value.font; + this.modelValue.settings.color = value.color; + this.modelValue.units = value.units; + this.modelValue.decimals = value.decimals; + this.propagateChange(this.modelValue); + } + + private _valuePreviewFn(): string { + const units: string = this.keyRowFormGroup.get('units').value; + const decimals: number = this.keyRowFormGroup.get('decimals').value; + return formatValue(22, decimals, units, true); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html new file mode 100644 index 0000000000..5ffbc2a018 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html @@ -0,0 +1,50 @@ + + + {{ 'widgets.aggregated-value-card.values' | translate }} + + + widgets.aggregated-value-card.position + widgets.aggregated-value-card.aggregation + widget-config.units-short + widget-config.decimals-short + widgets.aggregated-value-card.font + widgets.aggregated-value-card.color + + + + + + + + + + + + {{ 'widgets.aggregated-value-card.add-value' | translate }} + + + + + {{ 'widgets.aggregated-value-card.no-values' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss new file mode 100644 index 0000000000..9280b98ff6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.scss @@ -0,0 +1,69 @@ +/** + * 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-aggregated-data-keys-panel { + .tb-form-table-header-cell { + &.tb-position-header { + width: 132px; + min-width: 132px; + } + + &.tb-aggregation-header { + flex: 1; + min-width: 150px; + } + + &.tb-units-header { + width: 80px; + min-width: 80px; + } + + &.tb-decimals-header { + width: 60px; + min-width: 60px; + } + + &.tb-font-header { + width: 40px; + min-width: 40px; + } + + &.tb-color-header { + width: 40px; + min-width: 40px; + } + + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + + &.tb-units-header, &.tb-decimals-header { + display: none; + @media #{$mat-gt-sm} { + display: block; + } + } + } + .tb-form-table-body { + tb-aggregated-data-key-row { + overflow: hidden; + } + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts new file mode 100644 index 0000000000..d8b7f07f92 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts @@ -0,0 +1,173 @@ +/// +/// 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 { + ChangeDetectorRef, + Component, + forwardRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { UtilsService } from '@core/services/utils.service'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { aggregatedValueCardDefaultKeySettings } from '@home/components/widget/lib/cards/aggregated-value-card.models'; + +@Component({ + selector: 'tb-aggregated-data-keys-panel', + templateUrl: './aggregated-data-keys-panel.component.html', + styleUrls: ['./aggregated-data-keys-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregatedDataKeysPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class AggregatedDataKeysPanelComponent implements ControlValueAccessor, OnInit, OnChanges { + + @Input() + disabled: boolean; + + @Input() + datasourceType: DatasourceType; + + @Input() + keyName: string; + + dataKeyType: DataKeyType; + + keysListFormGroup: UntypedFormGroup; + + get widgetType(): widgetType { + return this.widgetConfigComponent.widgetType; + } + + get callbacks(): DataKeysCallbacks { + return this.widgetConfigComponent.widgetConfigCallbacks; + } + + get noKeys(): boolean { + const keys: DataKey[] = this.keysListFormGroup.get('keys').value; + return keys.length === 0; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef, + private utils: UtilsService, + private widgetConfigComponent: WidgetConfigComponent) { + } + + ngOnInit() { + this.keysListFormGroup = this.fb.group({ + keys: [this.fb.array([]), []] + }); + this.keysListFormGroup.valueChanges.subscribe( + (val) => this.propagateChange(this.keysListFormGroup.get('keys').value) + ); + this.updateParams(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['datasourceType'].includes(propName)) { + this.updateParams(); + } + } + } + } + + private updateParams() { + if (this.datasourceType === DatasourceType.function) { + this.dataKeyType = DataKeyType.function; + } else { + this.dataKeyType = DataKeyType.timeseries; + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.keysListFormGroup.disable({emitEvent: false}); + } else { + this.keysListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DataKey[] | undefined): void { + this.keysListFormGroup.setControl('keys', this.prepareKeysFormArray(value), {emitEvent: false}); + } + + keysFormArray(): UntypedFormArray { + return this.keysListFormGroup.get('keys') as UntypedFormArray; + } + + trackByKey(index: number, keyControl: AbstractControl): any { + return keyControl; + } + + removeKey(index: number) { + (this.keysListFormGroup.get('keys') as UntypedFormArray).removeAt(index); + } + + addKey() { + const dataKey = this.callbacks.generateDataKey(this.keyName, this.dataKeyType, null); + dataKey.decimals = 0; + dataKey.settings = {...aggregatedValueCardDefaultKeySettings}; + const keysArray = this.keysListFormGroup.get('keys') as UntypedFormArray; + const keyControl = this.fb.control(dataKey, []); + keysArray.push(keyControl); + } + + private prepareKeysFormArray(keys: DataKey[] | undefined): UntypedFormArray { + const keysControls: Array = []; + if (keys) { + keys.forEach((key) => { + keysControls.push(this.fb.control(key, [])); + }); + } + return this.fb.array(keysControls); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html new file mode 100644 index 0000000000..4c2742173b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.html @@ -0,0 +1,136 @@ + + + + + + + + widget-config.appearance + + + {{ 'widget-config.title' | translate }} + + + + + + + + + + + + + + {{ 'widgets.value-card.icon' | translate }} + + + + + + + + + + + + + + + {{ 'widgets.aggregated-value-card.subtitle' | translate }} + + + + + + + + + + + + + + {{ 'widgets.value-card.date' | translate }} + + + + + + + + + + + + {{ 'widgets.aggregated-value-card.chart' | translate }} + + + + + + + + + widget-config.card-appearance + + {{ 'widgets.background.background' | translate }} + + + + + widget-config.show-card-buttons + + {{ 'fullscreen.fullscreen' | translate }} + + + + {{ 'widget-config.card-border-radius' | translate }} + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts new file mode 100644 index 0000000000..2770539245 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts @@ -0,0 +1,293 @@ +/// +/// 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 { ChangeDetectorRef, Component, Injector } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { DataKey, Datasource, WidgetConfig, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { + getTimewindowConfig, + setTimewindowConfig +} from '@home/components/widget/config/timewindow-config-panel.component'; +import { isUndefined } from '@core/utils'; +import { + cssSizeToStrSize, + DateFormatProcessor, + DateFormatSettings, getDataKey, + resolveCssSize +} from '@shared/models/widget-settings.models'; +import { + aggregatedValueCardDefaultSettings, + AggregatedValueCardWidgetSettings, + createDefaultAggregatedValueLatestDataKeys +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; +import { + AggregationType, + HistoryWindowType, + HOUR, + QuickTimeInterval, + TimewindowType +} from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-aggregated-value-card-basic-config', + templateUrl: './aggregated-value-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class AggregatedValueCardBasicConfigComponent extends BasicWidgetConfigComponent { + + public get datasource(): Datasource { + const datasources: Datasource[] = this.aggregatedValueCardWidgetConfigForm.get('datasources').value; + if (datasources && datasources.length) { + return datasources[0]; + } else { + return null; + } + } + + public get keyName(): string { + const dataKey = getDataKey(this.aggregatedValueCardWidgetConfigForm.get('datasources').value); + if (dataKey) { + return dataKey.name; + } else { + return null; + } + } + + aggregatedValueCardWidgetConfigForm: UntypedFormGroup; + + datePreviewFn = this._datePreviewFn.bind(this); + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private cd: ChangeDetectorRef, + private $injector: Injector, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.aggregatedValueCardWidgetConfigForm; + } + + protected setupDefaults(configData: WidgetConfigComponentData) { + this.setupDefaultDatasource(configData, [ + { name: 'watermeter', label: 'Watermeter', type: DataKeyType.timeseries } + ], + createDefaultAggregatedValueLatestDataKeys('watermeter', 'm³') + ); + configData.config.useDashboardTimewindow = false; + configData.config.displayTimewindow = true; + configData.config.timewindow = { + selectedTab: TimewindowType.HISTORY, + history: { + historyType: HistoryWindowType.INTERVAL, + quickInterval: QuickTimeInterval.CURRENT_MONTH_SO_FAR, + }, + aggregation: { + type: AggregationType.AVG, + interval: 12 * HOUR, + limit: 5000 + } + }; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: AggregatedValueCardWidgetSettings = {...aggregatedValueCardDefaultSettings, ...(configData.config.settings || {})}; + const dataKey = getDataKey(configData.config.datasources); + const keyName = dataKey?.name; + const iconSize = resolveCssSize(configData.config.iconSize); + this.aggregatedValueCardWidgetConfigForm = this.fb.group({ + timewindowConfig: [getTimewindowConfig(configData.config), []], + datasources: [configData.config.datasources, []], + + showTitle: [configData.config.showTitle, []], + title: [configData.config.title, []], + titleFont: [configData.config.titleFont, []], + titleColor: [configData.config.titleColor, []], + + showIcon: [configData.config.showTitleIcon, []], + iconSize: [iconSize[0], [Validators.min(0)]], + iconSizeUnit: [iconSize[1], []], + icon: [configData.config.titleIcon, []], + iconColor: [configData.config.iconColor, []], + + showSubtitle: [settings.showSubtitle, []], + subtitle: [settings.subtitle, []], + subtitleFont: [settings.subtitleFont, []], + subtitleColor: [settings.subtitleColor, []], + + showDate: [settings.showDate, []], + dateFormat: [settings.dateFormat, []], + dateFont: [settings.dateFont, []], + dateColor: [settings.dateColor, []], + + showChart: [settings.showChart, []], + chartColor: [settings.chartColor, []], + + values: [this.getValues(configData.config.datasources, keyName), []], + + background: [settings.background, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + + actions: [configData.config.actions || {}, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); + this.widgetConfig.config.datasources = config.datasources; + + this.widgetConfig.config.showTitle = config.showTitle; + this.widgetConfig.config.title = config.title; + this.widgetConfig.config.titleFont = config.titleFont; + this.widgetConfig.config.titleColor = config.titleColor; + + this.widgetConfig.config.showTitleIcon = config.showIcon; + this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); + this.widgetConfig.config.titleIcon = config.icon; + this.widgetConfig.config.iconColor = config.iconColor; + + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + + this.widgetConfig.config.settings.showSubtitle = config.showSubtitle; + this.widgetConfig.config.settings.subtitle = config.subtitle; + this.widgetConfig.config.settings.subtitleFont = config.subtitleFont; + this.widgetConfig.config.settings.subtitleColor = config.subtitleColor; + + this.widgetConfig.config.settings.showDate = config.showDate; + this.widgetConfig.config.settings.dateFormat = config.dateFormat; + this.widgetConfig.config.settings.dateFont = config.dateFont; + this.widgetConfig.config.settings.dateColor = config.dateColor; + + this.widgetConfig.config.settings.showChart = config.showChart; + this.widgetConfig.config.settings.chartColor = config.chartColor; + + this.setValues(config.values, this.widgetConfig.config.datasources); + + this.widgetConfig.config.settings.background = config.background; + + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + + this.widgetConfig.config.actions = config.actions; + return this.widgetConfig; + } + + protected validatorTriggers(): string[] { + return ['showTitle', 'showIcon', 'showSubtitle', 'showDate', 'showChart']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showTitle: boolean = this.aggregatedValueCardWidgetConfigForm.get('showTitle').value; + const showIcon: boolean = this.aggregatedValueCardWidgetConfigForm.get('showIcon').value; + const showSubtitle: boolean = this.aggregatedValueCardWidgetConfigForm.get('showSubtitle').value; + const showDate: boolean = this.aggregatedValueCardWidgetConfigForm.get('showDate').value; + const showChart: boolean = this.aggregatedValueCardWidgetConfigForm.get('showChart').value; + + if (showTitle) { + this.aggregatedValueCardWidgetConfigForm.get('title').enable(); + this.aggregatedValueCardWidgetConfigForm.get('titleFont').enable(); + this.aggregatedValueCardWidgetConfigForm.get('titleColor').enable(); + this.aggregatedValueCardWidgetConfigForm.get('showIcon').enable({emitEvent: false}); + if (showIcon) { + this.aggregatedValueCardWidgetConfigForm.get('iconSize').enable(); + this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').enable(); + this.aggregatedValueCardWidgetConfigForm.get('icon').enable(); + this.aggregatedValueCardWidgetConfigForm.get('iconColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('iconSize').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').disable(); + this.aggregatedValueCardWidgetConfigForm.get('icon').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconColor').disable(); + } + } else { + this.aggregatedValueCardWidgetConfigForm.get('title').disable(); + this.aggregatedValueCardWidgetConfigForm.get('titleFont').disable(); + this.aggregatedValueCardWidgetConfigForm.get('titleColor').disable(); + this.aggregatedValueCardWidgetConfigForm.get('showIcon').disable({emitEvent: false}); + this.aggregatedValueCardWidgetConfigForm.get('iconSize').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconSizeUnit').disable(); + this.aggregatedValueCardWidgetConfigForm.get('icon').disable(); + this.aggregatedValueCardWidgetConfigForm.get('iconColor').disable(); + } + + if (showSubtitle) { + this.aggregatedValueCardWidgetConfigForm.get('subtitle').enable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleFont').enable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('subtitle').disable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleFont').disable(); + this.aggregatedValueCardWidgetConfigForm.get('subtitleColor').disable(); + } + + if (showDate) { + this.aggregatedValueCardWidgetConfigForm.get('dateFormat').enable(); + this.aggregatedValueCardWidgetConfigForm.get('dateFont').enable(); + this.aggregatedValueCardWidgetConfigForm.get('dateColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('dateFormat').disable(); + this.aggregatedValueCardWidgetConfigForm.get('dateFont').disable(); + this.aggregatedValueCardWidgetConfigForm.get('dateColor').disable(); + } + + if (showChart) { + this.aggregatedValueCardWidgetConfigForm.get('chartColor').enable(); + } else { + this.aggregatedValueCardWidgetConfigForm.get('chartColor').disable(); + } + } + + private getValues(datasources: Datasource[], keyName: string): DataKey[] { + if (datasources && datasources.length) { + return (datasources[0].latestDataKeys || []).filter(k => k.name === keyName); + } + return []; + } + + private setValues(values: DataKey[], datasources?: Datasource[]) { + if (datasources && datasources.length) { + datasources[0].latestDataKeys = values; + } + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } + + private _datePreviewFn(): string { + const dateFormat: DateFormatSettings = this.aggregatedValueCardWidgetConfigForm.get('dateFormat').value; + const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); + processor.update(Date.now()); + return processor.formatted; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html index 0b3ba50927..78f96d8264 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html @@ -18,7 +18,7 @@ {{ panelTitle }} - + datakey.source datakey.key datakey.label @@ -32,7 +32,7 @@ [cdkDropListDisabled]="!dragEnabled" (cdkDropListDropped)="keyDrop($event)"> datakey.general - + {{ 'entity.key' | translate }} + + + {{ 'timewindow.displayTypePrefix' | translate }} + + timewindow.preview diff --git a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts index e0e4ee5615..85e7e5a4be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts @@ -63,7 +63,8 @@ export class TimewindowStylePanelComponent extends PageComponent implements OnIn icon: [computedTimewindowStyle.icon, []], iconPosition: [computedTimewindowStyle.iconPosition, []], font: [computedTimewindowStyle.font, []], - color: [computedTimewindowStyle.color, []] + color: [computedTimewindowStyle.color, []], + displayTypePrefix: [computedTimewindowStyle.displayTypePrefix, []] } ); this.updatePreviewStyle(this.timewindowStyle); diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts index 9bda9345dc..fb5dd252b6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts @@ -119,7 +119,7 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.configForm().valid; } - protected setupDefaultDatasource(configData: WidgetConfigComponentData, keys?: DataKey[]) { + protected setupDefaultDatasource(configData: WidgetConfigComponentData, keys?: DataKey[], latestKeys?: DataKey[]) { let datasources = configData.config.datasources; if (!datasources || !datasources.length) { datasources = [ @@ -135,23 +135,58 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement dataKeys = []; datasources[0].dataKeys = dataKeys; } + let latestDataKeys = datasources[0].latestDataKeys; + if (!latestDataKeys) { + latestDataKeys = []; + datasources[0].latestDataKeys = latestDataKeys; + } if (keys && keys.length) { dataKeys.length = 0; keys.forEach(key => { - const dataKey = - this.widgetConfigComponent.widgetConfigCallbacks.generateDataKey(key.name, key.type, configData.dataKeySettingsSchema); - if (key.label) { - dataKey.label = key.label; - } - if (key.units) { - dataKey.units = key.units; - } - if (isDefinedAndNotNull(key.decimals)) { - dataKey.decimals = key.decimals; - } + const dataKey = this.constructDataKey(configData, key); dataKeys.push(dataKey); }); } + if (latestKeys && latestKeys.length) { + latestDataKeys.length = 0; + latestKeys.forEach(key => { + const dataKey = this.constructDataKey(configData, key); + latestDataKeys.push(dataKey); + }); + } + } + + protected constructDataKey(configData: WidgetConfigComponentData, key: DataKey): DataKey { + const dataKey = + this.widgetConfigComponent.widgetConfigCallbacks.generateDataKey(key.name, key.type, configData.dataKeySettingsSchema); + if (key.label) { + dataKey.label = key.label; + } + if (key.units) { + dataKey.units = key.units; + } + if (isDefinedAndNotNull(key.decimals)) { + dataKey.decimals = key.decimals; + } + if (isDefinedAndNotNull(key.settings)) { + dataKey.settings = key.settings; + } + if (isDefinedAndNotNull(key.aggregationType)) { + dataKey.aggregationType = key.aggregationType; + } + if (isDefinedAndNotNull(key.comparisonEnabled)) { + dataKey.comparisonEnabled = key.comparisonEnabled; + } + if (isDefinedAndNotNull(key.timeForComparison)) { + dataKey.timeForComparison = key.timeForComparison; + } + if (isDefinedAndNotNull(key.comparisonCustomIntervalValue)) { + dataKey.comparisonCustomIntervalValue = key.comparisonCustomIntervalValue; + } + if (isDefinedAndNotNull(key.comparisonResultType)) { + dataKey.comparisonResultType = key.comparisonResultType; + } + return dataKey; } protected abstract configForm(): UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts index 234443855f..1f54e0a996 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts @@ -903,7 +903,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, ).subscribe((res) => { if (res) { if (res) { - const tasks: Observable[] = []; + const tasks: Observable[] = []; for (const alarmId of alarmIds) { tasks.push(this.alarmService.ackAlarm(alarmId)); } @@ -959,7 +959,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, ).subscribe((res) => { if (res) { if (res) { - const tasks: Observable[] = []; + const tasks: Observable[] = []; for (const alarmId of alarmIds) { tasks.push(this.alarmService.clearAlarm(alarmId)); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html new file mode 100644 index 0000000000..b20431fa64 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + {{ subtitle$ | async }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{tickMax$ | async}} + {{tickMin$ | async}} + + + + + + + + + + + + + + + + + + arrow_upward + arrow_downward + + + {{ value.value }} + {{ value.units }} + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss new file mode 100644 index 0000000000..6dee828c36 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss @@ -0,0 +1,156 @@ +/** + * 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. + */ +:host { + .tb-aggregated-value-card-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 20px 24px 24px; + > div:not(.tb-value-card-overlay) { + z-index: 1; + } + .tb-aggregated-value-card-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + > div.tb-aggregated-value-card-title-panel { + display: flex; + flex-direction: column; + .tb-aggregated-value-card-subtitle { + margin-left: 28px; + } + } + .tb-aggregated-value-card-values, .tb-aggregated-value-card-chart { + flex: 1; + min-height: 0; + overflow: hidden; + } + .tb-aggregated-value-card-values-container { + width: 100%; + height: 100%; + padding: 8px 0; + display: grid; + grid-template-columns: minmax(0, 1fr) fit-content(100%) minmax(0, 1fr); + .tb-aggregated-value-card-values-section { + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + &.left { + align-items: flex-start; + } + &.center { + align-items: center; + } + &.right { + align-items: flex-end; + } + } + } + .tb-aggregated-value-card-chart { + display: flex; + gap: 8px; + flex-direction: row; + .tb-aggregated-value-card-chart-ticks { + height: 100%; + display: flex; + flex-direction: column; + place-content: flex-end space-between; + align-items: flex-end; + font-size: 11px; + line-height: 16px; + font-weight: 400; + color: rgba(0, 0, 0, 0.38); + } + .tb-aggregated-value-card-chart-container { + position: relative; + flex: 1; + margin-top: 8px; + margin-bottom: 8px; + .tb-aggregated-value-card-chart-element { + width: 100%; + height: 100%; + } + .tb-aggregated-value-card-chart-boundary { + position: absolute; + width: 6px; + height: 6px; + &.top { + top: 0; + border-top: 2px solid rgba(0,0,0,0.38); + } + &.left { + left: 0; + border-left: 2px solid rgba(0,0,0,0.38); + } + &.right { + right: 0; + border-right: 2px solid rgba(0,0,0,0.38); + } + &.bottom { + bottom: 0; + border-bottom: 2px solid rgba(0,0,0,0.38); + } + } + } + } + .tb-aggregated-value-card-value { + white-space: nowrap; + min-height: 0; + display: flex; + flex-direction: row; + place-content: center; + align-items: center; + .value-arrow-container { + display: flex; + } + .value-text { + line-height: 1; + } + .value-arrow { + font-size: 1.1em; + height: 1.1em; + line-height: 1.1em; + min-width: 1em; + width: 1em; + } + .units { + font-size: 85%; + padding-left: 0.2em; + &.small { + font-size: 50%; + } + } + } + } +} + +:host ::ng-deep { + .tb-aggregated-value-card-panel { + > div.tb-aggregated-value-card-title-panel { + .tb-widget-title { + padding: 0; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts new file mode 100644 index 0000000000..fca9f31084 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts @@ -0,0 +1,261 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnInit, + TemplateRef, + ViewChild +} from '@angular/core'; +import { + aggregatedValueCardDefaultSettings, + AggregatedValueCardKeyPosition, + AggregatedValueCardValue, + AggregatedValueCardWidgetSettings, + computeAggregatedCardValue, + getTsValueByLatestDataKey +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { Observable } from 'rxjs'; +import { + backgroundStyle, + ColorProcessor, + ComponentStyle, + DateFormatProcessor, getDataKey, + getLatestSingleTsValue, + overlayStyle, + textStyle +} from '@shared/models/widget-settings.models'; +import { DatePipe } from '@angular/common'; +import { TbFlot } from '@home/components/widget/lib/flot-widget'; +import { TbFlotKeySettings, TbFlotSettings } from '@home/components/widget/lib/flot-widget.models'; +import { DataKey } from '@shared/models/widget.models'; +import { formatNumberValue, formatValue, isDefined, isNumeric } from '@core/utils'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'tb-aggregated-value-card-widget', + templateUrl: './aggregated-value-card-widget.component.html', + styleUrls: ['./aggregated-value-card-widget.component.scss'] +}) +export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit { + + @ViewChild('chartElement', {static: false}) chartElement: ElementRef; + + aggregatedValueCardKeyPosition = AggregatedValueCardKeyPosition; + + settings: AggregatedValueCardWidgetSettings; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + showSubtitle = true; + subtitle$: Observable; + subtitleStyle: ComponentStyle = {}; + subtitleColor: ColorProcessor; + + showValues = false; + + values: {[key: string]: AggregatedValueCardValue} = {}; + + showChart = true; + chartColor: ColorProcessor; + + showDate = true; + dateFormat: DateFormatProcessor; + dateStyle: ComponentStyle = {}; + dateColor: ColorProcessor; + + backgroundStyle: ComponentStyle = {}; + overlayStyle: ComponentStyle = {}; + + private flot: TbFlot; + private flotDataKey: DataKey; + + private lastUpdateTs: number; + + tickMin$: Observable; + tickMax$: Observable; + + constructor(private date: DatePipe, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.aggregatedValueCardWidget = this; + this.settings = {...aggregatedValueCardDefaultSettings, ...this.ctx.settings}; + this.showSubtitle = this.settings.showSubtitle; + const subtitle = this.settings.subtitle; + this.subtitle$ = this.ctx.registerLabelPattern(subtitle, this.subtitle$); + this.subtitleStyle = textStyle(this.settings.subtitleFont, '0.25px'); + this.subtitleColor = ColorProcessor.fromSettings(this.settings.subtitleColor); + + const dataKey = getDataKey(this.ctx.defaultSubscription.datasources); + if (dataKey?.name && this.ctx.defaultSubscription.firstDatasource?.latestDataKeys?.length) { + const dataKeys = this.ctx.defaultSubscription.firstDatasource?.latestDataKeys; + for (const position of Object.keys(AggregatedValueCardKeyPosition)) { + const value = computeAggregatedCardValue(dataKeys, dataKey?.name, AggregatedValueCardKeyPosition[position]); + if (value) { + this.values[position] = value; + } + } + this.showValues = !!Object.keys(this.values).length; + } + + this.showChart = this.settings.showChart; + this.chartColor = ColorProcessor.fromSettings(this.settings.chartColor); + if (this.showChart) { + if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) { + this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0]; + this.flotDataKey.settings = { + fillLines: false, + showLines: true, + lineWidth: 2 + } as TbFlotKeySettings; + this.flotDataKey.color = this.chartColor.color; + } + } + + this.showDate = this.settings.showDate; + this.dateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.dateFormat); + this.dateStyle = textStyle(this.settings.dateFont, '0.25px'); + this.dateColor = ColorProcessor.fromSettings(this.settings.dateColor); + + this.backgroundStyle = backgroundStyle(this.settings.background); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + } + + ngAfterViewInit(): void { + if (this.showChart) { + const settings = { + shadowSize: 0, + enableSelection: false, + smoothLines: false, + grid: { + tickColor: 'rgba(0,0,0,0.12)', + horizontalLines: true, + verticalLines: false, + outlineWidth: 0, + minBorderMargin: 0, + margin: 0 + }, + yaxis: { + showLabels: false, + tickGenerator: 'return [(axis.max + axis.min) / 2];' + }, + xaxis: { + showLabels: false + } + } as TbFlotSettings; + this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings); + this.tickMin$ = this.flot.yMin$.pipe( + map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals), + (this.flotDataKey?.units || this.ctx.units)) + )); + this.tickMax$ = this.flot.yMax$.pipe( + map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals), + (this.flotDataKey?.units || this.ctx.units)) + )); + } + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + public onDataUpdated() { + const tsValue = getLatestSingleTsValue(this.ctx.data); + let ts; + let value; + if (tsValue) { + ts = tsValue[0]; + value = tsValue[1]; + } + this.subtitleColor.update(value); + this.dateColor.update(value); + + if (this.showChart) { + this.chartColor.update(value); + this.flot.updateSeriesColor(this.chartColor.color); + this.flot.update(); + } + + this.updateLastUpdateTs(ts); + this.cd.detectChanges(); + } + + public onLatestDataUpdated() { + if (this.showValues) { + for (const aggValue of Object.values(this.values)) { + const tsValue = getTsValueByLatestDataKey(this.ctx.latestData, aggValue.key); + let ts; + let value; + if (tsValue) { + ts = tsValue[0]; + value = tsValue[1]; + aggValue.value = formatValue(value, (aggValue.key.decimals || this.ctx.decimals), null, false); + } else { + aggValue.value = 'N/A'; + } + const numeric = formatNumberValue(value, (aggValue.key.decimals || this.ctx.decimals)); + aggValue.color.update(numeric); + if (aggValue.showArrow && isDefined(numeric)) { + aggValue.upArrow = numeric > 0; + aggValue.downArrow = numeric < 0; + } else { + aggValue.upArrow = aggValue.downArrow = false; + } + this.updateLastUpdateTs(ts); + } + this.cd.detectChanges(); + } + } + + public onResize() { + if (this.showChart) { + this.flot.resize(); + } + } + + public onEditModeChanged() { + if (this.showChart) { + this.flot.checkMouseEvents(); + } + } + + public onDestroy() { + if (this.showChart) { + this.flot.destroy(); + } + } + + private updateLastUpdateTs(ts: number) { + if (ts && (!this.lastUpdateTs || ts > this.lastUpdateTs)) { + this.lastUpdateTs = ts; + this.dateFormat.update(ts); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts new file mode 100644 index 0000000000..273ca1edaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts @@ -0,0 +1,232 @@ +/// +/// 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 { + BackgroundSettings, + BackgroundType, + ColorProcessor, + ColorSettings, + ColorType, + ComponentStyle, + constantColor, + DateFormatSettings, + Font, + iconStyle, + lastUpdateAgoDateFormat, + textStyle +} from '@shared/models/widget-settings.models'; +import { ComparisonResultType, DataKey, DatasourceData } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AggregationType } from '@shared/models/time/time.models'; + +export interface AggregatedValueCardWidgetSettings { + showSubtitle: boolean; + subtitle: string; + subtitleFont: Font; + subtitleColor: ColorSettings; + showDate: boolean; + dateFormat: DateFormatSettings; + dateFont: Font; + dateColor: ColorSettings; + showChart: boolean; + chartColor: ColorSettings; + background: BackgroundSettings; +} + +export enum AggregatedValueCardKeyPosition { + center = 'center', + rightTop = 'rightTop', + rightBottom = 'rightBottom', + leftTop = 'leftTop', + leftBottom = 'leftBottom' +} + +export const aggregatedValueCardKeyPositionTranslations = new Map( + [ + [AggregatedValueCardKeyPosition.center, 'widgets.aggregated-value-card.position-center'], + [AggregatedValueCardKeyPosition.rightTop, 'widgets.aggregated-value-card.position-right-top'], + [AggregatedValueCardKeyPosition.rightBottom, 'widgets.aggregated-value-card.position-right-bottom'], + [AggregatedValueCardKeyPosition.leftTop, 'widgets.aggregated-value-card.position-left-top'], + [AggregatedValueCardKeyPosition.leftBottom, 'widgets.aggregated-value-card.position-left-bottom'] + ] +); + +export interface AggregatedValueCardKeySettings { + position: AggregatedValueCardKeyPosition; + font: Font; + color: ColorSettings; + showArrow: boolean; +} + +export interface AggregatedValueCardValue { + key: DataKey; + value: string; + units: string; + style: ComponentStyle; + color: ColorProcessor; + center: boolean; + showArrow: boolean; + upArrow: boolean; + downArrow: boolean; +} + +export const computeAggregatedCardValue = (dataKeys: DataKey[], keyName: string, position: AggregatedValueCardKeyPosition): AggregatedValueCardValue => { + const key = dataKeys.find(dataKey => ( dataKey.name === keyName && (dataKey.settings?.position === position || + (!dataKey.settings?.position && position === AggregatedValueCardKeyPosition.center)) )); + if (key) { + const settings: AggregatedValueCardKeySettings = key.settings; + return { + key, + value: '', + units: key.units, + style: textStyle(settings.font, '0.25px'), + color: ColorProcessor.fromSettings(settings.color), + center: position === AggregatedValueCardKeyPosition.center, + showArrow: settings.showArrow, + upArrow: false, + downArrow: false + }; + } +}; + +export const getTsValueByLatestDataKey = (latestData: Array, dataKey: DataKey): [number, any] => { + if (latestData?.length) { + const dsData = latestData.find(data => data.dataKey === dataKey); + if (dsData?.data?.length) { + return dsData.data[0]; + } + } + return null; +}; + +export const aggregatedValueCardDefaultSettings: AggregatedValueCardWidgetSettings = { + showSubtitle: true, + subtitle: '${entityName}', + subtitleFont: { + family: 'Roboto', + size: 12, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '16px' + }, + subtitleColor: constantColor('rgba(0, 0, 0, 0.38)'), + showDate: true, + dateFormat: lastUpdateAgoDateFormat(), + dateFont: { + family: 'Roboto', + size: 12, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '16px' + }, + dateColor: constantColor('rgba(0, 0, 0, 0.38)'), + showChart: true, + chartColor: constantColor('rgba(0, 0, 0, 0.87)'), + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; + +export const aggregatedValueCardDefaultKeySettings: AggregatedValueCardKeySettings = { + position: AggregatedValueCardKeyPosition.center, + font: { + family: 'Roboto', + size: 14, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1' + }, + color: constantColor('rgba(0, 0, 0, 0.87)'), + showArrow: false +}; + +export const createDefaultAggregatedValueLatestDataKeys = (keyName: string, units): DataKey[] => [ + { + name: keyName, label: keyName, type: DataKeyType.timeseries, units, decimals: 0, + aggregationType: AggregationType.NONE, + settings: { + position: AggregatedValueCardKeyPosition.center, + font: { + family: 'Roboto', + size: 52, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1' + }, + color: constantColor('rgba(0, 0, 0, 0.87)'), + showArrow: false + } as AggregatedValueCardKeySettings + }, + { + name: keyName, label: 'Delta percent ' + keyName, type: DataKeyType.timeseries, units: '%', decimals: 0, + aggregationType: AggregationType.AVG, + comparisonEnabled: true, + timeForComparison: 'previousInterval', + comparisonResultType: ComparisonResultType.DELTA_PERCENT, + settings: { + position: AggregatedValueCardKeyPosition.rightTop, + font: { + family: 'Roboto', + size: 14, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1' + }, + color: { + color: 'rgba(0, 0, 0, 0.87)', + type: ColorType.range, + rangeList: [ + {to: 0, color: '#198038'}, + {from: 0, to: 0, color: 'rgba(0, 0, 0, 0.87)'}, + {from: 0, color: '#D12730'} + ], + colorFunction: '' + }, + showArrow: true + } as AggregatedValueCardKeySettings + }, + { + name: keyName, label: 'Delta absolute ' + keyName, type: DataKeyType.timeseries, units, decimals: 1, + aggregationType: AggregationType.AVG, + comparisonEnabled: true, + timeForComparison: 'previousInterval', + comparisonResultType: ComparisonResultType.DELTA_ABSOLUTE, + settings: { + position: AggregatedValueCardKeyPosition.rightBottom, + font: { + family: 'Roboto', + size: 11, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '1' + }, + color: constantColor('rgba(0, 0, 0, 0.38)'), + showArrow: false + } as AggregatedValueCardKeySettings + } + ]; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html index fee7dc5259..377e0a31f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html @@ -66,7 +66,7 @@ {{ label$ | async }} - {{ dateFormat.formatted }} + {{ valueText }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts index 64267a5637..907878368d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts @@ -83,7 +83,7 @@ export function loadNodeCtxFunction any>(functionB } export function materialIconHtml(materialIcon: string): string { - return '' + materialIcon + ''; + return '' + materialIcon + ''; } export function iconUrlHtml(iconUrl: string): string { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts index 844d7540ff..7faa9e9de3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts @@ -132,6 +132,7 @@ export interface TbFlotYAxisSettings { ticksFormatter: string; tickDecimals: number; tickSize: number; + tickGenerator: string; } export interface TbFlotBaseSettings { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts index 1d20068e20..e38bb3b098 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts @@ -18,7 +18,8 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { createLabelFromDatasource, - deepClone, formattedDataFormDatasourceData, + deepClone, + formattedDataFormDatasourceData, insertVariable, isDefined, isDefinedAndNotNull, @@ -59,6 +60,7 @@ import { AggregationType } from '@shared/models/time/time.models'; import { CancelAnimationFrame } from '@core/services/raf.service'; import { UtilsService } from '@core/services/utils.service'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { BehaviorSubject } from 'rxjs'; import Timeout = NodeJS.Timeout; const moment = moment_; @@ -130,9 +132,15 @@ export class TbFlot { private pieAnimationLastTime: number; private pieAnimationCaf: CancelAnimationFrame; - constructor(private ctx: WidgetContext, private readonly chartType: ChartType, private $flotElement?: JQuery) { + private yMinSubject = new BehaviorSubject(-1); + private yMaxSubject = new BehaviorSubject(1); + + yMin$ = this.yMinSubject.asObservable(); + yMax$ = this.yMaxSubject.asObservable(); + + constructor(private ctx: WidgetContext, private readonly chartType: ChartType, private $flotElement?: JQuery, settings?: TbFlotSettings) { this.chartType = this.chartType || 'line'; - this.settings = ctx.settings as TbFlotSettings; + this.settings = settings || (ctx.settings as TbFlotSettings); this.utils = this.ctx.$injector.get(UtilsService); this.enableSelection = isDefined(this.settings.enableSelection) ? this.settings.enableSelection : true; this.selectionMode = this.enableSelection ? 'x' : null; @@ -209,6 +217,12 @@ export class TbFlot { } else { this.yaxis.tickSize = null; } + if (this.settings.yaxis.tickGenerator?.length) { + try { + this.yaxis.ticks = new Function('axis', + this.settings.yaxis.tickGenerator); + } catch (e) {} + } if (isNumber(this.settings.yaxis.tickDecimals)) { this.yaxis.tickDecimals = this.settings.yaxis.tickDecimals; } else { @@ -717,6 +731,15 @@ export class TbFlot { } } + public updateSeriesColor(color: string) { + if (this.subscription?.data?.length) { + const series = this.subscription.data[0] as TbFlotSeries; + series.dataKey.color = color; + series.color = color; + series.highlightColor = tinycolor(color).setAlpha(.75).toRgbString(); + } + } + private latestDataByDataIndex(index: number): FormattedData { if (this.latestData[index]) { return this.latestData[index]; @@ -802,6 +825,8 @@ export class TbFlot { clearTimeout(this.resizeTimeoutHandle); this.resizeTimeoutHandle = null; } + this.yMinSubject.complete(); + this.yMaxSubject.complete(); } private createPlot() { @@ -818,6 +843,7 @@ export class TbFlot { } else { this.plot = $.plot(this.$element, this.subscription.data, this.options) as JQueryPlot; } + this.updateYMinMax(); } else { this.createPlotTimeoutHandle = setTimeout(this.createPlot.bind(this), 30); } @@ -830,6 +856,20 @@ export class TbFlot { this.plot.setupGrid(); } this.plot.draw(); + this.updateYMinMax(); + } + + private updateYMinMax() { + if (this.plot?.getYAxes().length) { + const min = this.plot?.getYAxes()[0].min; + const max = this.plot?.getYAxes()[0].max; + if (this.yMinSubject.value !== min) { + this.yMinSubject.next(min); + } + if (this.yMaxSubject.value !== max) { + this.yMaxSubject.next(max); + } + } } private redrawPlot() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html new file mode 100644 index 0000000000..32d48347ac --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html @@ -0,0 +1,48 @@ + + + + widgets.aggregated-value-card.value-appearance + + {{ 'widgets.aggregated-value-card.position' | translate }} + + + + {{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} + + + + + + {{ 'widgets.aggregated-value-card.font' | translate }} + + + + + {{ 'widgets.aggregated-value-card.color' | translate }} + + + + + + {{ 'widgets.aggregated-value-card.display-up-down-arrow' | translate }} + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts new file mode 100644 index 0000000000..e527c3b3d4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts @@ -0,0 +1,64 @@ +/// +/// 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 } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + aggregatedValueCardDefaultKeySettings, + AggregatedValueCardKeyPosition, + aggregatedValueCardKeyPositionTranslations +} from '@home/components/widget/lib/cards/aggregated-value-card.models'; +import { constantColor } from '@shared/models/widget-settings.models'; + +@Component({ + selector: 'tb-aggregated-value-card-key-settings', + templateUrl: './aggregated-value-card-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class AggregatedValueCardKeySettingsComponent extends WidgetSettingsComponent { + + aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] = + Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]); + + aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations; + + aggregatedValueCardKeySettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.aggregatedValueCardKeySettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...aggregatedValueCardDefaultKeySettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.aggregatedValueCardKeySettingsForm = this.fb.group({ + position: [settings.position, []], + font: [settings.font, []], + color: [settings.color, []], + showArrow: [settings.showArrow, []] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts index 46454937f6..9241f60dda 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts @@ -74,7 +74,8 @@ export const flotDefaultSettings = (chartType: ChartType): Partial - mdi:function-variant + mdi:function-variant diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts index 6d8ad1eefe..f92203459e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts @@ -99,7 +99,7 @@ export class ColorSettingsComponent implements OnInit, ControlValueAccessor { } private updateColorStyle() { - if (!this.disabled) { + if (!this.disabled && this.modelValue) { let colors: string[] = [this.modelValue.color]; if (this.modelValue.type === ColorType.range && this.modelValue.rangeList?.length) { const rangeColors = this.modelValue.rangeList.slice(0, Math.min(2, this.modelValue.rangeList.length)).map(r => r.color); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index 1e4010faaa..0f467ba917 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -267,6 +267,9 @@ import { ValueCardWidgetSettingsComponent } from '@home/components/widget/lib/settings/cards/value-card-widget-settings.component'; import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module'; +import { + AggregatedValueCardKeySettingsComponent +} from '@home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component'; @NgModule({ declarations: [ @@ -366,7 +369,8 @@ import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings TripAnimationWidgetSettingsComponent, DocLinksWidgetSettingsComponent, QuickLinksWidgetSettingsComponent, - ValueCardWidgetSettingsComponent + ValueCardWidgetSettingsComponent, + AggregatedValueCardKeySettingsComponent ], imports: [ CommonModule, @@ -471,7 +475,8 @@ import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings TripAnimationWidgetSettingsComponent, DocLinksWidgetSettingsComponent, QuickLinksWidgetSettingsComponent, - ValueCardWidgetSettingsComponent + ValueCardWidgetSettingsComponent, + AggregatedValueCardKeySettingsComponent ] }) export class WidgetSettingsModule { @@ -541,5 +546,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type + + + + + + {{notificationTemplateTypeTranslateMap.get(notificationSettingsFormGroup.get('name').value)?.name | translate}} + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.scss b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.scss new file mode 100644 index 0000000000..ee37181a09 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.scss @@ -0,0 +1,24 @@ +/** + * 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. + */ +:host { + .notification-type { + font-size: 14px; + + &-disabled { + color: rgba(0, 0, 0, 0.38) + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts new file mode 100644 index 0000000000..76e9babf00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts @@ -0,0 +1,126 @@ +/// +/// 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, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { UtilsService } from '@core/services/utils.service'; +import { isDefinedAndNotNull } from '@core/utils'; +import { Subscription } from 'rxjs'; +import { + NotificationDeliveryMethod, + NotificationTemplateTypeTranslateMap, + NotificationUserSetting +} from '@shared/models/notification.models'; + +@Component({ + selector: 'tb-notification-setting-form', + templateUrl: './notification-setting-form.component.html', + styleUrls: ['./notification-setting-form.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NotificationSettingFormComponent), + multi: true + } + ] +}) +export class NotificationSettingFormComponent implements ControlValueAccessor, OnInit, OnDestroy { + + @Input() + disabled: boolean; + + @Input() + deliveryMethods: NotificationDeliveryMethod[] = []; + + @Input() + allowDeliveryMethods: NotificationDeliveryMethod[] = []; + + notificationSettingsFormGroup: UntypedFormGroup; + + notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap; + + private propagateChange = null; + + private valueChange$: Subscription = null; + + constructor(private utils: UtilsService, + private fb: UntypedFormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + const deliveryMethod = {}; + this.deliveryMethods.forEach(value => { + deliveryMethod[value] = true; + }); + this.notificationSettingsFormGroup = this.fb.group( + { + name: [''], + enabled: [true], + enabledDeliveryMethods: this.fb.group({ + ...deliveryMethod + }) + }); + this.valueChange$ = this.notificationSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + ngOnDestroy() { + if (this.valueChange$) { + this.valueChange$.unsubscribe(); + this.valueChange$ = null; + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.notificationSettingsFormGroup.disable({emitEvent: false}); + } else { + this.notificationSettingsFormGroup.enable({emitEvent: false}); + } + } + + toggleEnabled() { + this.notificationSettingsFormGroup.get('enabled').patchValue(!this.notificationSettingsFormGroup.get('enabled').value); + } + + getChecked(deliveryMethod: NotificationDeliveryMethod): boolean { + return this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod).value; + } + + toggleDeliviryMethod(deliveryMethod: NotificationDeliveryMethod) { + this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod) + .patchValue(!this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod).value); + } + + writeValue(value: NotificationUserSetting): void { + if (isDefinedAndNotNull(value)) { + this.notificationSettingsFormGroup.patchValue(value, {emitEvent: false}); + } + } + + private updateModel() { + this.propagateChange(this.notificationSettingsFormGroup.value); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings-routing.modules.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings-routing.modules.ts new file mode 100644 index 0000000000..6a65e554bf --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings-routing.modules.ts @@ -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. +/// + +import { Routes } from '@angular/router'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Authority } from '@shared/models/authority.enum'; +import { inject, NgModule } from '@angular/core'; +import { NotificationSettingsComponent } from '@home/pages/notification/settings/notification-settings.component'; +import { NotificationService } from '@core/http/notification.service'; + +export const notificationUserSettingsRoutes: Routes = [ + { + path: 'notificationSettings', + component: NotificationSettingsComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'account.notification-settings', + breadcrumb: { + label: 'account.notification-settings', + icon: 'settings' + } + }, + resolve: { + userSettings: () => inject(NotificationService).getNotificationUserSettings() + } + } +]; diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html new file mode 100644 index 0000000000..c531f10d78 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html @@ -0,0 +1,84 @@ + + + + + + + notification.settings.notification-settings + + + {{ 'notification.settings.reset-all' | translate }} + + + + + + + + + + + + + + notification.settings.type + + + + + + {{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }} + + + + + + + + + + + + + + + + + {{ 'action.save' | translate }} + + + + diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.scss b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.scss new file mode 100644 index 0000000000..a91c57ed65 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-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. + */ +@import "../../../../../../scss/constants"; + +:host { + .mat-mdc-card.settings-card { + margin: 8px; + @media #{$mat-gt-sm} { + width: 60%; + } + .mat-headline-5 { + margin: 0; + } + .notification-form { + height: 100%; + min-height: min-content; + max-height: min-content; + } + .notification-section { + height: 100%; + border: 1px solid rgba(0, 0, 0, 0.12); + overflow: scroll; + &-block { + min-width: 470px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts new file mode 100644 index 0000000000..c28e030eec --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts @@ -0,0 +1,188 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { + NotificationDeliveryMethod, + NotificationDeliveryMethodTranslateMap, + NotificationUserSettings +} from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { DialogService } from '@core/services/dialog.service'; + +@Component({ + selector: 'tb-notification-settings', + templateUrl: './notification-settings.component.html', + styleUrls: ['./notification-settings.component.scss'] +}) +export class NotificationSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { + + notificationSettings: UntypedFormGroup; + + notificationDeliveryMethods: NotificationDeliveryMethod[]; + notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap; + + allowNotificationDeliveryMethods: Array; + + constructor(protected store: Store, + private route: ActivatedRoute, + private translate: TranslateService, + private dialogService: DialogService, + private notificationService: NotificationService, + private fb: UntypedFormBuilder,) { + super(store); + } + + ngOnInit() { + this.notificationDeliveryMethods = this.getNotificationDeliveryMethods(); + + this.notificationService.getAvailableDeliveryMethods({ignoreLoading: true}).subscribe(allowMethods => { + this.allowNotificationDeliveryMethods = allowMethods; + }); + + this.buildNotificationSettingsForm(); + this.patchNotificationSettings(this.route.snapshot.data.userSettings); + } + + private getNotificationDeliveryMethods(): NotificationDeliveryMethod[] { + const deliveryMethods = new Set([ + NotificationDeliveryMethod.SLACK + ]); + return Object.values(NotificationDeliveryMethod).filter(type => !deliveryMethods.has(type)); + } + + private buildNotificationSettingsForm() { + this.notificationSettings = this.fb.group({ + prefs: this.fb.array([]) + }); + } + + private patchNotificationSettings(settings: NotificationUserSettings) { + const notificationSettingsControls: Array = []; + let preparedSettings; + if (settings.prefs) { + preparedSettings = this.prepareNotificationSettings(settings.prefs); + preparedSettings.forEach((setting) => { + setting.enabledDeliveryMethods = Object.assign( + this.notificationDeliveryMethods.reduce((a, v) => ({ ...a, [v]: true}), {}), + setting.enabledDeliveryMethods + ); + notificationSettingsControls.push(this.fb.control(setting, [Validators.required])); + }); + } + this.notificationSettings.setControl('prefs', this.fb.array(notificationSettingsControls), {emitEvent: false}); + } + + private prepareNotificationSettings(prefs: any) { + return Object.entries(prefs).map((value: any) => { + value[1].name = value[0]; + return value[1]; + }); + } + + resetSettings() { + this.dialogService.confirm( + this.translate.instant('notification.settings.reset-all-title'), + this.translate.instant('notification.settings.reset-all-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe( + result => { + if (result) { + const settings = this.prepareNotificationSettings(this.route.snapshot.data.userSettings.prefs); + const notificationSettingsControls: Array = []; + this.notificationSettings.reset({}); + if (settings) { + settings.forEach((setting) => { + setting.enabled = true; + setting.enabledDeliveryMethods = this.notificationDeliveryMethods.reduce((a, v) => ({ ...a, [v]: true}), {}); + notificationSettingsControls.push(this.fb.control(setting, [Validators.required])); + }); + } + this.notificationSettings.setControl('prefs', this.fb.array(notificationSettingsControls), {emitEvent: false}); + this.save(); + } + } + ); + } + + getChecked = (method: NotificationDeliveryMethod = null): boolean => { + const type = this.notificationSettings.get('prefs').value; + if (isDefinedAndNotNull(method)) { + return isDefinedAndNotNull(type) && type.every(resource => resource.enabledDeliveryMethods[method]); + } + return isDefinedAndNotNull(type) && type.every(resource => resource.enabled); + }; + + getSomeChecked = () => { + const type = this.notificationSettings.get('prefs').value; + return isDefinedAndNotNull(type) && type.some(resource => resource.enabled); + }; + + getIndeterminate = (deliveryMethod: NotificationDeliveryMethod = null): boolean => { + const type = this.notificationSettings.get('prefs').value; + if (isDefinedAndNotNull(type)) { + const checkedResource = isDefinedAndNotNull(deliveryMethod) ? + type.filter(resource => resource.enabledDeliveryMethods[deliveryMethod]) : + type.filter(resource => resource.enabled); + return checkedResource.length !== 0 && checkedResource.length !== type.length; + } + return false; + }; + + changeInstanceTypeCheckBox = (value: boolean, deliveryMethod: NotificationDeliveryMethod = null): void => { + const type = deepClone(this.notificationSettings.get('prefs').value); + if (isDefinedAndNotNull(deliveryMethod)) { + type.forEach(notificationType => notificationType.enabledDeliveryMethods[deliveryMethod] = value); + } else { + type.forEach(notificationType => notificationType.enabled = value); + } + this.notificationSettings.get('prefs').patchValue(type); + this.notificationSettings.markAsDirty(); + }; + + get notificationSettingsFormArray(): UntypedFormArray { + return this.notificationSettings.get('prefs') as UntypedFormArray; + } + + save(): void { + const settings = {prefs: {}}; + this.notificationSettings.getRawValue().prefs.forEach(value => { + const key = value.name; + delete value.name; + settings.prefs[key] = value; + }); + this.notificationService.saveNotificationUserSettings(settings).subscribe( + (userSettings) => { + this.notificationSettings.get('prefs').reset({}); + this.patchNotificationSettings(userSettings); + } + ); + } + + confirmForm(): UntypedFormGroup { + return this.notificationSettings; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss index aeba412d9b..e71400c8e1 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss @@ -20,6 +20,10 @@ min-width: 750px !important; max-width: 750px !important; + .mat-mdc-dialog-content { + padding: 8px; + } + @media #{$mat-lt-md} { min-width: 100% !important; } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss index e3cf6699b2..827f77b865 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss @@ -27,10 +27,7 @@ display: flex; flex-direction: column; gap: 8px; - - @media #{$mat-gt-sm} { - margin-bottom: 24px; - } + margin-bottom: 22px; } @media #{$mat-gt-sm} { diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html index 3ad2ef2824..b3454006b6 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html @@ -15,7 +15,8 @@ limitations under the License. --> - + {{ label }} - 1" - type="button" - matTooltip="{{'entity.clear-selected-profiles' | translate}}" - matSuffix mat-icon-button - (click)="clearChipGrid()"> - close - + + + {{ subtypeListEmptyText | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts index 1442b44cfd..a10bf7e3e2 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts @@ -15,7 +15,7 @@ /// import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { Observable, Subscription, throwError } from 'rxjs'; import { map, mergeMap, publishReplay, refCount, share } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -23,14 +23,15 @@ import { AppState } from '@app/core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { EntitySubtype, EntityType } from '@shared/models/entity-type.models'; import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; -import { MatChipInputEvent, MatChipGrid } from '@angular/material/chips'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { MatChipGrid, MatChipInputEvent } from '@angular/material/chips'; import { AssetService } from '@core/http/asset.service'; import { DeviceService } from '@core/http/device.service'; import { EdgeService } from '@core/http/edge.service'; import { EntityViewService } from '@core/http/entity-view.service'; import { BroadcastService } from '@core/services/broadcast.service'; import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { FloatLabelType } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-subtype-list', @@ -46,32 +47,43 @@ import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; }) export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { - entitySubtypeListFormGroup: UntypedFormGroup; + entitySubtypeListFormGroup: FormGroup; modelValue: Array | null; private requiredValue: boolean; + get required(): boolean { return this.requiredValue; } - @Input() label: string; - @Input() + @coerceBoolean() set required(value: boolean) { - const newVal = coerceBooleanProperty(value); - if (this.requiredValue !== newVal) { - this.requiredValue = newVal; + if (this.requiredValue !== value) { + this.requiredValue = value; this.updateValidators(); } } + @Input() + floatLabel: FloatLabelType = 'auto'; + + @Input() + label: string; + @Input() disabled: boolean; @Input() entityType: EntityType; + @Input() + emptyInputPlaceholder: string; + + @Input() + filledInputPlaceholder: string; + @ViewChild('entitySubtypeInput') entitySubtypeInput: ElementRef; @ViewChild('entitySubtypeAutocomplete') entitySubtypeAutocomplete: MatAutocomplete; @ViewChild('chipList', {static: true}) chipList: MatChipGrid; @@ -102,13 +114,14 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, private deviceService: DeviceService, private edgeService: EdgeService, private entityViewService: EntityViewService, - private fb: UntypedFormBuilder) { + private fb: FormBuilder) { this.entitySubtypeListFormGroup = this.fb.group({ entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []], entitySubtype: [null] }); } + updateValidators() { this.entitySubtypeListFormGroup.get('entitySubtypeList').setValidators(this.required ? [Validators.required] : []); this.entitySubtypeListFormGroup.get('entitySubtypeList').updateValueAndValidity(); @@ -122,7 +135,6 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, } ngOnInit() { - switch (this.entityType) { case EntityType.ASSET: this.placeholder = this.required ? this.translate.instant('asset.enter-asset-type') @@ -166,6 +178,13 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, break; } + if (this.emptyInputPlaceholder) { + this.placeholder = this.emptyInputPlaceholder; + } + if (this.filledInputPlaceholder) { + this.secondaryPlaceholder = this.filledInputPlaceholder; + } + this.filteredEntitySubtypeList = this.entitySubtypeListFormGroup.get('entitySubtype').valueChanges .pipe( map(value => value ? value : ''), @@ -225,13 +244,6 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, } this.clear(''); } - - clearChipGrid() { - this.entitySubtypeList = []; - this.modelValue = null; - this.entitySubtypeListFormGroup.get('entitySubtypeList').patchValue([], {emitEvent: true}); - } - remove(entitySubtype: string) { const index = this.entitySubtypeList.indexOf(entitySubtype); if (index >= 0) { diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html index 0503ce836b..e9168d1f29 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html @@ -15,7 +15,11 @@ limitations under the License. --> - + {{ label }} + + + {{ 'entity.entity-type-list-empty' | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts index 17f908b2e0..7bb52d0948 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts @@ -15,7 +15,7 @@ /// import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Observable, of } from 'rxjs'; import { filter, map, mergeMap, share, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -25,9 +25,8 @@ import { AliasEntityType, EntityType, entityTypeTranslations } from '@shared/mod import { EntityService } from '@core/http/entity.service'; import { MatAutocomplete } from '@angular/material/autocomplete'; import { MatChipGrid } from '@angular/material/chips'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { FloatLabelType, SubscriptSizing } from '@angular/material/form-field'; -import { coerceBoolean } from '@shared/decorators/coercion'; +import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; interface EntityTypeInfo { name: string; @@ -48,7 +47,7 @@ interface EntityTypeInfo { }) export class EntityTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit { - entityTypeListFormGroup: UntypedFormGroup; + entityTypeListFormGroup: FormGroup; modelValue: Array | null; @@ -57,19 +56,28 @@ export class EntityTypeListComponent implements ControlValueAccessor, OnInit, Af return this.requiredValue; } - @Input() label: string; - - @Input() floatLabel: FloatLabelType = 'auto'; - @Input() + @coerceBoolean() set required(value: boolean) { - const newVal = coerceBooleanProperty(value); - if (this.requiredValue !== newVal) { - this.requiredValue = newVal; + if (this.requiredValue !== value) { + this.requiredValue = value; this.updateValidators(); } } + @Input() + @coerceArray() + additionalClasses: Array; + + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + label: string; + + @Input() + floatLabel: FloatLabelType = 'auto'; + @Input() disabled: boolean; @@ -79,6 +87,12 @@ export class EntityTypeListComponent implements ControlValueAccessor, OnInit, Af @Input() allowedEntityTypes: Array; + @Input() + emptyInputPlaceholder: string; + + @Input() + filledInputPlaceholder: string; + @Input() @coerceBoolean() ignoreAuthorityFilter: boolean; @@ -103,7 +117,7 @@ export class EntityTypeListComponent implements ControlValueAccessor, OnInit, Af constructor(private store: Store, public translate: TranslateService, private entityService: EntityService, - private fb: UntypedFormBuilder) { + private fb: FormBuilder) { this.entityTypeListFormGroup = this.fb.group({ entityTypeList: [this.entityTypeList, this.required ? [Validators.required] : []], entityType: [null] @@ -123,11 +137,17 @@ export class EntityTypeListComponent implements ControlValueAccessor, OnInit, Af } ngOnInit() { - - this.placeholder = this.required ? this.translate.instant('entity.enter-entity-type') - : this.translate.instant('entity.any-entity'); - this.secondaryPlaceholder = '+' + this.translate.instant('entity.entity-type'); - + if (this.emptyInputPlaceholder) { + this.placeholder = this.emptyInputPlaceholder; + } else { + this.placeholder = this.required ? this.translate.instant('entity.enter-entity-type') : + this.translate.instant('entity.any-entity'); + } + if (this.filledInputPlaceholder) { + this.secondaryPlaceholder = this.filledInputPlaceholder; + } else { + this.secondaryPlaceholder = '+' + this.translate.instant('entity.entity-type'); + } let entityTypes: Array; if (this.ignoreAuthorityFilter && this.allowedEntityTypes && this.allowedEntityTypes.length) { @@ -250,5 +270,4 @@ export class EntityTypeListComponent implements ControlValueAccessor, OnInit, Af this.entityTypeInput.nativeElement.focus(); }, 0); } - } diff --git a/ui-ngx/src/app/shared/components/help-popup.component.html b/ui-ngx/src/app/shared/components/help-popup.component.html index 1a9fe5541a..730d054b8b 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.html +++ b/ui-ngx/src/app/shared/components/help-popup.component.html @@ -30,19 +30,21 @@ - + + [class]="{'mat-mdc-outlined-button mdc-button--outlined': popoverVisible && popoverReady, + 'hint-button': hintMode}"> - open_in_new - + open_in_new + diff --git a/ui-ngx/src/app/shared/components/help-popup.component.scss b/ui-ngx/src/app/shared/components/help-popup.component.scss index 2ec26a3c0f..6be4d9b71c 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.scss +++ b/ui-ngx/src/app/shared/components/help-popup.component.scss @@ -17,6 +17,9 @@ width: initial; display: inline-block; vertical-align: middle; + &.hint-button { + line-height: 1; + } } .tb-help-popup-button { @@ -65,4 +68,19 @@ vertical-align: middle; } } + &.hint-button { + padding: 2px 3px; + line-height: 1; + &.mat-mdc-outlined-button { + padding: 1px 2px; + } + .mdc-button__label > span { + .mat-icon { + margin-right: 0; + } + .mat-mdc-progress-spinner { + margin-right: 0; + } + } + } } diff --git a/ui-ngx/src/app/shared/components/help-popup.component.ts b/ui-ngx/src/app/shared/components/help-popup.component.ts index 08be61f728..64722348a2 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.ts +++ b/ui-ngx/src/app/shared/components/help-popup.component.ts @@ -28,6 +28,7 @@ import { TbPopoverService } from '@shared/components/popover.service'; import { PopoverPlacement } from '@shared/components/popover.models'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { isDefinedAndNotNull } from '@core/utils'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -62,6 +63,11 @@ export class HelpPopupComponent implements OnChanges, OnDestroy { popoverVisible = false; popoverReady = true; + + @Input() + @coerceBoolean() + hintMode = false; + triggerSafeHtml: SafeHtml = null; textMode = false; diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html index c057f1df09..99a650098e 100644 --- a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html @@ -15,7 +15,8 @@ limitations under the License. --> - + {{ label }} ; - @Input() floatLabel: FloatLabelType = 'auto'; + @Input() + appearance: MatFormFieldAppearance = 'fill'; @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + floatLabel: FloatLabelType = 'auto'; + + @Input() + @coerceBoolean() + required: boolean; @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.html b/ui-ngx/src/app/shared/components/string-items-list.component.html index 4677cbc3de..7416e96870 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.html +++ b/ui-ngx/src/app/shared/components/string-items-list.component.html @@ -54,7 +54,15 @@ {{ 'common.not-found' | translate }} - {{ hint }} + + {{ hint }} + + + + + + + {{ requiredText }} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.ts b/ui-ngx/src/app/shared/components/time/timewindow.component.ts index ba8b4ba114..6205667245 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.ts @@ -89,6 +89,11 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan return this.historyOnlyValue; } + get displayTypePrefix(): boolean { + return isDefinedAndNotNull(this.computedTimewindowStyle?.displayTypePrefix) + ? this.computedTimewindowStyle?.displayTypePrefix : true; + } + @HostBinding('class.no-margin') @Input() @coerceBoolean() @@ -198,6 +203,7 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan if (!change.firstChange && change.currentValue !== change.previousValue) { if (propName === 'timewindowStyle') { this.updateTimewindowStyle(); + this.updateDisplayValue(); } } } @@ -316,7 +322,7 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan updateDisplayValue() { if (this.innerValue.selectedTab === TimewindowType.REALTIME && !this.historyOnly) { - this.innerValue.displayValue = this.translate.instant('timewindow.realtime') + ' - '; + this.innerValue.displayValue = this.displayTypePrefix ? (this.translate.instant('timewindow.realtime') + ' - ') : ''; if (this.innerValue.realtime.realtimeType === RealtimeWindowType.INTERVAL) { this.innerValue.displayValue += this.translate.instant(QuickTimeIntervalTranslationMap.get(this.innerValue.realtime.quickInterval)); } else { @@ -324,7 +330,7 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan this.millisecondsToTimeStringPipe.transform(this.innerValue.realtime.timewindowMs); } } else { - this.innerValue.displayValue = (!this.historyOnly || this.alwaysDisplayTypePrefix) ? + this.innerValue.displayValue = this.displayTypePrefix && (!this.historyOnly || this.alwaysDisplayTypePrefix) ? (this.translate.instant('timewindow.history') + ' - ') : ''; if (this.innerValue.history.historyType === HistoryWindowType.LAST_INTERVAL) { this.innerValue.displayValue += this.translate.instant('timewindow.last-prefix') + ' ' + diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts index edafef4704..0d1a46443d 100644 --- a/ui-ngx/src/app/shared/models/notification.models.ts +++ b/ui-ngx/src/app/shared/models/notification.models.ts @@ -591,3 +591,12 @@ export const TriggerTypeTranslationMap = new Map([ [TriggerType.NEW_PLATFORM_VERSION, 'notification.trigger.new-platform-version'], [TriggerType.RATE_LIMITS, 'notification.trigger.rate-limits'], ]); + +export interface NotificationUserSettings { + prefs: {[key: string]: NotificationUserSetting}; +} + +export interface NotificationUserSetting { + enabled: boolean; + enabledDeliveryMethods: {[key: string]: boolean}; +} diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 50cbef2f8b..d93dde4530 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -61,6 +61,13 @@ export enum TelemetryFeature { TIMESERIES = 'TIMESERIES' } +export enum TimeseriesDeleteStrategy { + DELETE_ALL_DATA = 'DELETE_ALL_DATA', + DELETE_ALL_DATA_EXCEPT_LATEST_VALUE = 'DELETE_ALL_DATA_EXCEPT_LATEST_VALUE', + DELETE_LATEST_VALUE = 'DELETE_LATEST_VALUE', + DELETE_ALL_DATA_FOR_TIME_PERIOD = 'DELETE_ALL_DATA_FOR_TIME_PERIOD' +} + export type TelemetryType = LatestTelemetry | AttributeScope; export const toTelemetryType = (val: string): TelemetryType => { @@ -73,7 +80,7 @@ export const toTelemetryType = (val: string): TelemetryType => { export const telemetryTypeTranslations = new Map( [ - [LatestTelemetry.LATEST_TELEMETRY, 'attribute.scope-latest-telemetry'], + [LatestTelemetry.LATEST_TELEMETRY, 'attribute.scope-telemetry'], [AttributeScope.CLIENT_SCOPE, 'attribute.scope-client'], [AttributeScope.SERVER_SCOPE, 'attribute.scope-server'], [AttributeScope.SHARED_SCOPE, 'attribute.scope-shared'] @@ -89,6 +96,15 @@ export const isClientSideTelemetryType = new Map( ] ); +export const timeseriesDeleteStrategyTranslations = new Map( + [ + [TimeseriesDeleteStrategy.DELETE_ALL_DATA, 'attribute.delete-timeseries.all-data'], + [TimeseriesDeleteStrategy.DELETE_ALL_DATA_EXCEPT_LATEST_VALUE, 'attribute.delete-timeseries.all-data-except-latest-value'], + [TimeseriesDeleteStrategy.DELETE_LATEST_VALUE, 'attribute.delete-timeseries.latest-value'], + [TimeseriesDeleteStrategy.DELETE_ALL_DATA_FOR_TIME_PERIOD, 'attribute.delete-timeseries.all-data-for-time-period'] + ] +) + export interface AttributeData { lastUpdateTs?: number; key: string; diff --git a/ui-ngx/src/app/shared/models/widget-settings.models.ts b/ui-ngx/src/app/shared/models/widget-settings.models.ts index 18782cdd93..fb15622754 100644 --- a/ui-ngx/src/app/shared/models/widget-settings.models.ts +++ b/ui-ngx/src/app/shared/models/widget-settings.models.ts @@ -17,7 +17,7 @@ import { isDefinedAndNotNull, isNumber, isNumeric, parseFunction } from '@core/utils'; import { DataKey, Datasource, DatasourceData } from '@shared/models/widget.models'; import { Injector } from '@angular/core'; -import { DatePipe, formatDate } from '@angular/common'; +import { DatePipe } from '@angular/common'; import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; import { TranslateService } from '@ngx-translate/core'; @@ -97,13 +97,15 @@ export interface TimewindowStyle { iconPosition: 'left' | 'right'; font?: Font; color?: string; + displayTypePrefix?: boolean; } export const defaultTimewindowStyle: TimewindowStyle = { showIcon: true, icon: 'query_builder', iconSize: '24px', - iconPosition: 'left' + iconPosition: 'left', + displayTypePrefix: true }; export const constantColor = (color: string): ColorSettings => ({ @@ -117,18 +119,44 @@ export const constantColor = (color: string): ColorSettings => ({ 'return \'blue\';' }); +export const cssSizeToStrSize = (size?: number, unit?: cssUnit): string => (isDefinedAndNotNull(size) ? size + '' : '0') + (unit || 'px'); + +export const resolveCssSize = (strSize?: string): [number, cssUnit] => { + if (!strSize || !strSize.trim().length) { + return [0, 'px']; + } + let resolvedUnit: cssUnit; + let resolvedSize = strSize; + for (const unit of cssUnits) { + if (strSize.endsWith(unit)) { + resolvedUnit = unit; + break; + } + } + if (resolvedUnit) { + resolvedSize = strSize.substring(0, strSize.length - resolvedUnit.length); + } + resolvedUnit = resolvedUnit || 'px'; + let numericSize = 0; + if (isNumeric(resolvedSize)) { + numericSize = Number(resolvedSize); + } + return [numericSize, resolvedUnit]; +}; + type ValueColorFunction = (value: any) => string; export abstract class ColorProcessor { static fromSettings(color: ColorSettings): ColorProcessor { - switch (color.type) { + const settings = color || constantColor('rgba(0, 0, 0, 0.87)'); + switch (settings.type) { case ColorType.constant: - return new ConstantColorProcessor(color); + return new ConstantColorProcessor(settings); case ColorType.range: - return new RangeColorProcessor(color); + return new RangeColorProcessor(settings); case ColorType.function: - return new FunctionColorProcessor(color); + return new FunctionColorProcessor(settings); } } @@ -164,13 +192,19 @@ class RangeColorProcessor extends ColorProcessor { if (this.settings.rangeList?.length && isDefinedAndNotNull(value) && isNumeric(value)) { const num = Number(value); for (const range of this.settings.rangeList) { - if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) { + if (this.constantRange(range) && range.from === num) { + return range.color; + } else if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) { return range.color; } } } return this.settings.color; } + + private constantRange(range: ColorRange): boolean { + return isNumber(range.from) && isNumber(range.to) && range.from === range.to; + } } class FunctionColorProcessor extends ColorProcessor { @@ -242,7 +276,7 @@ export abstract class DateFormatProcessor { } } - formatted = ''; + formatted = ' '; protected constructor(protected $injector: Injector, protected settings: DateFormatSettings) { @@ -412,3 +446,13 @@ export const getSingleTsValue = (data: Array): [number, any] => } return null; }; + +export const getLatestSingleTsValue = (data: Array): [number, any] => { + if (data.length) { + const dsData = data[0]; + if (dsData.data.length) { + return dsData.data[dsData.data.length - 1]; + } + } + return null; +}; diff --git a/ui-ngx/src/assets/locale/locale.constant-ca_ES.json b/ui-ngx/src/assets/locale/locale.constant-ca_ES.json index c36061c6a5..34735dc1c5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ca_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-ca_ES.json @@ -632,6 +632,7 @@ "attributes": "Atributs", "latest-telemetry": "Última telemetria", "attributes-scope": "Abast dels atributs del dispositiu", + "scope-telemetry": "Telemetria", "scope-latest-telemetry": "Última telemetria", "scope-client": "Atributs del Client", "scope-server": "Atributs del Servidor", diff --git a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json index d89971cd0c..25325acab7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json +++ b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json @@ -445,6 +445,7 @@ "attributes": "Atributy", "latest-telemetry": "Poslední telemetrie", "attributes-scope": "Rozsah atributů entity", + "scope-telemetry": "Telemetrie", "scope-latest-telemetry": "Poslední telemetrie", "scope-client": "Atributy klienta", "scope-server": "Atributy serveru", diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json index 4e3b3ca779..1c26dd45a7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-da_DK.json +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -453,6 +453,7 @@ "attributes": "Attributter", "latest-telemetry": "Seneste telemetri", "attributes-scope": "Omfang af entitetsattributter", + "scope-telemetry": "Telemetri", "scope-latest-telemetry": "Seneste telemetri", "scope-client": "Klientattributter", "scope-server": "Serverattributter", diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index ed73ad25cf..d7b50d51c3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -324,6 +324,7 @@ "attributes": "Eigenschaften", "latest-telemetry": "Neueste Telemetrie", "attributes-scope": "Entitätseigenschaftsbereich", + "scope-telemetry": "Telemetrie", "scope-latest-telemetry": "Neueste Telemetrie", "scope-client": "Client Eigenschaften", "scope-server": "Server Eigenschaften", diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index 453b5dd83c..9c5ac0db54 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -291,6 +291,7 @@ "attributes": "Χαρακτηριστικά", "latest-telemetry": "Τελευταία τηλεμετρία", "attributes-scope": "Πεδίο εφαρμογής Χαρακτηριστικών Οντότητας", + "scope-telemetry": "Τηλεμετρία", "scope-latest-telemetry": "Τελευταία τηλεμετρία", "scope-client": "Χαρακτηριστικά Client", "scope-server": "Χαρακτηριστικά Server", diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index f90a34fe8e..d161526958 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -11,7 +11,8 @@ "permission-denied-text": "You don't have permission to perform this operation!" }, "account": { - "account": "Account" + "account": "Account", + "notification-settings": "Notification settings" }, "action": { "activate": "Activate", @@ -512,6 +513,7 @@ "severity-indeterminate": "Indeterminate", "acknowledge": "Acknowledge", "clear": "Clear", + "delete": "Delete", "search": "Search alarms", "selected-alarms": "{ count, plural, =1 {1 alarm} other {# alarms} } selected", "no-data": "No data to display", @@ -522,10 +524,14 @@ "aknowledge-alarms-text": "Are you sure you want to acknowledge { count, plural, =1 {1 alarm} other {# alarms} }?", "aknowledge-alarm-title": "Acknowledge Alarm", "aknowledge-alarm-text": "Are you sure you want to acknowledge Alarm?", + "selected-alarms-are-acknowledged": "Selected alarms are already acknowledged", "clear-alarms-title": "Clear { count, plural, =1 {1 alarm} other {# alarms} }", "clear-alarms-text": "Are you sure you want to clear { count, plural, =1 {1 alarm} other {# alarms} }?", "clear-alarm-title": "Clear Alarm", "clear-alarm-text": "Are you sure you want to clear Alarm?", + "delete-alarms-title": "Delete { count, plural, =1 {1 alarm} other {# alarms} }", + "delete-alarms-text": "Are you sure you want to delete { count, plural, =1 {1 alarm} other {# alarms} }?", + "selected-alarms-are-cleared": "Selected alarms are already cleared", "alarm-status-filter": "Alarm Status Filter", "alarm-filter-title": "Alarm Filter", "assigned": "Assigned", @@ -699,6 +705,7 @@ "latest-telemetry": "Latest telemetry", "no-latest-telemetry": "No latest telemetry", "attributes-scope": "Entity attributes scope", + "scope-telemetry": "Telemetry", "scope-latest-telemetry": "Latest telemetry", "scope-client": "Client attributes", "scope-server": "Server attributes", @@ -726,7 +733,19 @@ "no-telemetry-text": "No telemetry found", "copy-key": "Copy key", "copy-value": "Copy value", - "add-telemetry": "Add telemetry" + "add-telemetry": "Add telemetry", + "copy-value": "Copy value", + "delete-timeseries": { + "start-time": "Start time", + "ends-on": "Ends on", + "strategy": "Strategy", + "delete-strategy": "Delete strategy", + "all-data": "Delete all data", + "all-data-except-latest-value": "Delete all data except latest value", + "latest-value": "Delete latest value", + "all-data-for-time-period": "Delete all data for time period", + "rewrite-latest-value": "Rewrite latest value" + } }, "api-usage": { "api-features": "API features", @@ -1208,7 +1227,11 @@ "delta-calculation-result-delta-absolute": "Delta (absolute)", "delta-calculation-result-delta-percent": "Delta (percent)", "source": "Source", - "latest": "Latest" + "latest": "Latest", + "latest-value": "Latest value", + "delta": "delta", + "percent": "percent", + "absolute": "absolute" }, "datasource": { "type": "Datasource type", @@ -2031,6 +2054,7 @@ "entity-types": "Entity types", "entity-type-list": "Entity type list", "any-entity": "Any entity", + "add-entity-type": "Add entity type", "enter-entity-type": "Enter entity type", "no-entities-matching": "No entities matching '{{entity}}' were found.", "no-entity-types-matching": "No entity types matching '{{entityType}}' were found.", @@ -3128,7 +3152,17 @@ "updated": "Updated", "use-template": "Use template", "view-all": "View all", - "warning": "Warning" + "warning": "Warning", + "settings": { + "notification-settings": "Notification settings", + "reset-all": "Reset all settings", + "reset-all-title": "Are you sure you want to reset form?", + "reset-all-text": "After the confirmation, the settings form will reset to the default value and save.", + "type": "Type", + "enable-all": "Enable all", + "disable-all": "Disable all", + "delivery-not-configured": "Delivery method is not configured" + } }, "ota-update": { "add": "Add package", @@ -3325,6 +3359,7 @@ "delete-from-relations-title": "Are you sure you want to delete { count, plural, =1 {1 relation} other {# relations} }?", "delete-from-relations-text": "Be careful, after the confirmation all selected relations will be removed and current entity will be unrelated from the corresponding entities.", "remove-relation-filter": "Remove relation filter", + "remove-filter": "Remove filter", "add-relation-filter": "Add relation filter", "any-relation": "Any relation", "relation-filters": "Relation filters", @@ -3911,6 +3946,7 @@ "icon-position-right": "Right", "font": "Font", "color": "Color", + "displayTypePrefix": "Display Realtime/History prefix", "preview": "Preview" }, "unit": { @@ -5718,6 +5754,25 @@ "date": "Date", "value-card-style": "Value card style" }, + "aggregated-value-card": { + "subtitle": "Subtitle", + "chart": "Chart", + "values": "Values", + "value-appearance": "Value appearance", + "position": "Position", + "position-center": "Center", + "position-right-top": "Right top", + "position-right-bottom": "Right bottom", + "position-left-top": "Left top", + "position-left-bottom": "Left bottom", + "font": "Font", + "color": "Color", + "display-up-down-arrow": "Display Up/Down arrow", + "add-value": "Add value", + "remove-value": "Remove value", + "no-values": "No values configured", + "aggregation": "Aggregation" + }, "table": { "common-table-settings": "Common Table Settings", "enable-search": "Enable search", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index 8aba862e65..cbbaf4b5a1 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -667,6 +667,7 @@ "attributes": "Atributos", "latest-telemetry": "Última telemetría", "attributes-scope": "Alcance de los atributos del dispositivo", + "scope-telemetry": "Telemetría", "scope-latest-telemetry": "Última telemetría", "scope-client": "Atributos de Cliente", "scope-server": "Atributos de Servidor", diff --git a/ui-ngx/src/assets/locale/locale.constant-fa_IR.json b/ui-ngx/src/assets/locale/locale.constant-fa_IR.json index 6e5026b011..6ab40820ec 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fa_IR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fa_IR.json @@ -254,6 +254,7 @@ "attributes": "ويژگي ها", "latest-telemetry": "آخرين سنجش", "attributes-scope": "حوزه ويژگي هاي موجودي", + "scope-telemetry": "تله متری", "scope-latest-telemetry": "آخرين سنجش", "scope-client": "ويژگي هاي مشتري", "scope-server": "ويژگي هاي سِروِر", diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 56712eeeb5..3db8795df6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -459,6 +459,7 @@ "next-widget": "Widget suivant", "prev-widget": "Widget précédent", "scope-client": "Attributs du client", + "scope-telemetry": "Télémétrie", "scope-latest-telemetry": "Dernière télémétrie", "scope-server": "Attributs du serveur", "scope-shared": "Attributs partagés", diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index 94acd17f82..61f6554049 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -276,6 +276,7 @@ "attributes": "Attributi", "latest-telemetry": "Ultima telemetria", "attributes-scope": "Visibilità attributi entità", + "scope-telemetry": "Telemetria", "scope-latest-telemetry": "Ultima telemetria", "scope-client": "Attributi client", "scope-server": "Attributi server", diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json index f63a6681a2..56130aabc3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json @@ -244,6 +244,7 @@ "attributes": "属性", "latest-telemetry": "最新テレメトリ", "attributes-scope": "エンティティ属性のスコープ", + "scope-telemetry": "テレメトリー", "scope-latest-telemetry": "最新テレメトリ", "scope-client": "クライアントの属性", "scope-server": "サーバーの属性", diff --git a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json index a6c5a6576d..55b6e85f66 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json +++ b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json @@ -290,6 +290,7 @@ "attributes": "ატრიბუტები", "latest-telemetry": "უახლესი ტელემეტრია", "attributes-scope": "ობიექტის ატრიბუტების ფარგლები", + "scope-telemetry": "ტელემეტრია", "scope-latest-telemetry": "უახლესი ტელემეტრია", "scope-client": "კლიენტის ატრიბუტები", "scope-server": "სერვერის ატრიბუტები", diff --git a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json index 0fd1ba039d..ae53707a52 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json +++ b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json @@ -408,6 +408,7 @@ "attributes": "속성", "latest-telemetry": "최근 데이터", "attributes-scope": "장치 속성 범위", + "scope-telemetry": "원격 측정", "scope-latest-telemetry": "최근 데이터", "scope-client": "클라이언트 속성", "scope-server": "서버 속성", diff --git a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json index d584f2da96..00e6be4714 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json +++ b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json @@ -256,6 +256,7 @@ "attributes": "Attribūti", "latest-telemetry": "Jaunākā telemetrija", "attributes-scope": "Vienības atribūtu darbības joma", + "scope-telemetry": "Telemetrija", "scope-latest-telemetry": "Jaunākā telemetrija", "scope-client": "Klientu atribūti", "scope-server": "Servera atribūti", diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json index 2bba0338d2..12ad7743c4 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -309,6 +309,7 @@ "attributes": "Atributos", "latest-telemetry": "Última telemetria", "attributes-scope": "Escopo de atributos de entidade", + "scope-telemetry": "Telemetria", "scope-latest-telemetry": "Última telemetria", "scope-client": "Atributos do cliente", "scope-server": "Atributos do servidor", diff --git a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json index fa5cee48c9..4b565d8311 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json +++ b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json @@ -285,6 +285,7 @@ "attributes": "Atribute", "latest-telemetry": "Ultimele Date Telemetrice", "attributes-scope": "Scop Atribute Entitate", + "scope-telemetry": "Telemetrie", "scope-latest-telemetry": "Ultimele Date Telemetrice", "scope-client": "Atribute Client", "scope-server": "Atribute Server", diff --git a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json index fcc2f6f867..0515b5890e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json +++ b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json @@ -408,6 +408,7 @@ "attributes": "Lastnosti", "latest-telemetry": "Najnovejša telemetrija", "attributes-scope": "Obseg atributov entitete", + "scope-telemetry": "Telemetrija", "scope-latest-telemetry": "Najnovejša telemetrija", "scope-client": "Atributi odjemalca", "scope-server": "Atributi strežnika", diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index cd7e31e97f..8590dd2768 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -445,6 +445,7 @@ "attributes": "Öznitelikler", "latest-telemetry": "Son telemetri", "attributes-scope": "Varlık öznitelik kapsamı", + "scope-telemetry": "telemetri", "scope-latest-telemetry": "Son telemetri", "scope-client": "İstemci öznitelikler", "scope-server": "Sunucu öznitelikler", diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index 7aa541e57f..c7afaad234 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -342,6 +342,7 @@ "attributes": "Атрибути", "latest-telemetry": "Остання телеметрія", "attributes-scope": "Область видимості атрибутів", + "scope-telemetry": "Телеметрія", "scope-latest-telemetry": "Остання телеметрія", "scope-client": "Клієнтські атрибути", "scope-server": "Серверні атрибути", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 1a083e45fb..6e5bf9a6b6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -590,6 +590,7 @@ "attributes": "属性", "latest-telemetry": "最新遥测数据", "attributes-scope": "设备属性范围", + "scope-telemetry": "遥测", "scope-latest-telemetry": "最新遥测数据", "scope-client": "客户端属性", "scope-server": "服务端属性", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json index a5e6a2bc97..2b98451f20 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json @@ -519,6 +519,7 @@ "attributes": "屬性", "latest-telemetry": "最新遙測", "attributes-scope": "設備屬性範圍", + "scope-telemetry": "遙測", "scope-latest-telemetry": "最新遙測", "scope-client": "客戶端屬性", "scope-server": "服務端屬性", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index bef8bf621e..c5115add1b 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -122,18 +122,6 @@ font: inherit; } } - .mat-slide { - margin: 0; - &.margin { - margin: 8px 0; - } - .mdc-form-field>label { - font-weight: 400; - font-size: 16px; - line-height: 24px; - margin-left: 12px; - } - } } .tb-form-panel-title { @@ -200,6 +188,21 @@ } } + .tb-form-panel, .tb-form-row { + .mat-slide { + margin: 0; + &.margin { + margin: 8px 0; + } + .mdc-form-field>label { + font-weight: 400; + font-size: 16px; + line-height: 24px; + margin-left: 12px; + } + } + } + .tb-form-row .mat-mdc-form-field, .mat-mdc-form-field.tb-inline-field { &.mat-form-field-appearance-fill { .mdc-text-field--filled:not(.mdc-text-field--disabled) { @@ -307,6 +310,10 @@ } } &.tb-chips { + &.flex { + flex: 1; + width: auto; + } .mat-mdc-text-field-wrapper { &.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) { .mat-mdc-form-field-infix { @@ -357,7 +364,7 @@ } .tb-prompt { - height: 38px; + height: 40px; } } @@ -366,11 +373,16 @@ flex-direction: row; gap: 8px; padding-left: 8px; + padding-right: 8px; place-content: center flex-start; align-items: center; + &.no-padding-right { + padding-right: 0; + } @media #{$mat-gt-md} { gap: 12px; padding-left: 12px; + padding-right: 12px; } &-cell { font-weight: 400; @@ -388,12 +400,18 @@ } } - .tb-form-table-row { - &.tb-draggable { - gap: 0; - padding-left: 0; - background: #fff; + .tb-draggable-form-table-row { + background: #fff; + display: flex; + flex-direction: row; + place-content: center flex-start; + align-items: center; + .tb-form-table-row { + padding-right: 0; } + } + + .tb-form-table-row { &-cell { color: rgba(0, 0, 0, 0.87); diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index ec6060823d..8e6a9dde75 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -855,6 +855,9 @@ mat-label { svg { vertical-align: inherit; } + &.tb-mat-12 { + @include tb-mat-icon-size(12); + } &.tb-mat-16 { @include tb-mat-icon-size(16); } @@ -1208,4 +1211,7 @@ mat-label { color: inherit; } + .cursor-pointer { + cursor: pointer; + } }