diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json index b19c5d40b9..8a80aa80f1 100644 --- a/application/src/main/data/json/system/widget_bundles/input_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/input_widgets.json @@ -455,6 +455,24 @@ "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } + }, + { + "alias": "update_json_attribute", + "name": "Update JSON attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new JSON value for pre-defined attribute/timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.jsonInputWidget.onDataUpdated();\n}\n\nself.onResize = function() {\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n}", + "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"AdvancedSettings\",\n \"properties\": {\n \"widgetTitle\": {\n \"title\": \"Widget title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"widgetMode\": {\n \"title\": \"Widget mode\",\n \"type\": \"string\",\n \"default\": \"ATTRIBUTE\"\n },\n \"attributeScope\": {\n \"title\": \"Attribute scope\",\n \"type\": \"string\",\n \"default\": \"SERVER_SCOPE\"\n },\n \"showLabel\":{\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"labelValue\": {\n \"title\": \"Label\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"attributeRequired\": {\n \"title\": \"Value required\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"showResultMessage\": {\n \"title\": \"Show result message\",\n \"type\": \"boolean\",\n \"default\": true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"widgetTitle\",\n {\n \"key\": \"widgetMode\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"ATTRIBUTE\",\n \"label\": \"Update attribute\"\n },\n {\n \"value\": \"TIME_SERIES\",\n \"label\": \"Update timeseries\"\n }\n ]\n },\n {\n \"key\": \"attributeScope\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"condition\": \"model.widgetMode === 'ATTRIBUTE'\",\n \"items\": [\n {\n \"value\": \"SERVER_SCOPE\",\n \"label\": \"Server attribute\"\n },\n {\n \"value\": \"SHARED_SCOPE\",\n \"label\": \"Shared attribute\"\n }\n ]\n },\n \"showLabel\",\n {\n \"key\": \"labelValue\",\n \"condition\": \"model.showLabel\"\n },\n \"attributeRequired\",\n \"showResultMessage\"\n ]\n}", + "dataKeySettingsSchema": "{}", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"attributeScope\":\"SERVER_SCOPE\",\"showLabel\":true,\"attributeRequired\":true,\"showResultMessage\":true},\"title\":\"Update JSON attribute\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + } } ] } diff --git a/application/src/main/java/org/thingsboard/server/config/WebConfig.java b/application/src/main/java/org/thingsboard/server/config/WebConfig.java index 81d3390dd2..500d1bd06e 100644 --- a/application/src/main/java/org/thingsboard/server/config/WebConfig.java +++ b/application/src/main/java/org/thingsboard/server/config/WebConfig.java @@ -21,7 +21,7 @@ import org.springframework.web.bind.annotation.RequestMapping; @Controller public class WebConfig { - @RequestMapping(value = "/{path:^(?!api$)(?!assets$)(?!static$)(?!webjars$)[^\\.]*}/**") + @RequestMapping(value = {"/assets", "/assets/", "/{path:^(?!api$)(?!assets$)(?!static$)(?!webjars$)[^\\.]*}/**"}) public String redirect() { return "forward:/index.html"; } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 3d21c66e23..1ba2ce3470 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -17,8 +17,10 @@ package org.thingsboard.server.service.install; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java index ee2b3bac20..52638a99e2 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java @@ -40,7 +40,7 @@ import org.thingsboard.server.transport.lwm2m.utils.TypeServer; import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.UUID; @@ -70,8 +70,7 @@ public class LwM2MBootstrapSecurityStore implements BootstrapSecurityStore { @Override public List getAllByEndpoint(String endPoint) { - String endPointKey = endPoint; - ReadResultSecurityStore store = lwM2MCredentialsSecurityInfoValidator.createAndValidateCredentialsSecurityInfo(endPointKey, TypeServer.BOOTSTRAP); + ReadResultSecurityStore store = lwM2MCredentialsSecurityInfoValidator.createAndValidateCredentialsSecurityInfo(endPoint, TypeServer.BOOTSTRAP); if (store.getBootstrapJsonCredential() != null && store.getSecurityMode() < LwM2MSecurityMode.DEFAULT_MODE.code) { /** add value to store from BootstrapJson */ this.setBootstrapConfigScurityInfo(store); @@ -87,7 +86,7 @@ public class LwM2MBootstrapSecurityStore implements BootstrapSecurityStore { } catch (InvalidConfigurationException e) { log.error("", e); } - return store.getSecurityInfo() == null ? null : Arrays.asList(store.getSecurityInfo()); + return store.getSecurityInfo() == null ? null : Collections.singletonList(store.getSecurityInfo()); } } return null; diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java similarity index 84% rename from ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss rename to common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java index 97c400525e..dfc88a3331 100644 --- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package org.thingsboard.server.transport.lwm2m.server; -:host { - min-width: 364px; +public interface LwM2mQueuedRequest { + void send(); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java index a879e4bc78..e237146c8a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.server; import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.coap.Response; import org.eclipse.leshan.core.attributes.Attribute; import org.eclipse.leshan.core.attributes.AttributeSet; @@ -34,6 +35,7 @@ import org.eclipse.leshan.core.request.ObserveRequest; import org.eclipse.leshan.core.request.ReadRequest; import org.eclipse.leshan.core.request.WriteAttributesRequest; import org.eclipse.leshan.core.request.WriteRequest; +import org.eclipse.leshan.core.request.exception.ClientSleepingException; import org.eclipse.leshan.core.response.CancelObservationResponse; import org.eclipse.leshan.core.response.DeleteResponse; import org.eclipse.leshan.core.response.DiscoverResponse; @@ -58,7 +60,6 @@ import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static org.eclipse.californium.core.coap.CoAP.ResponseCode.isSuccess; import static org.eclipse.leshan.core.attributes.Attribute.MINIMUM_PERIOD; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEFAULT_TIMEOUT; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_DISCOVER; @@ -215,13 +216,19 @@ public class LwM2mTransportRequest { request = new WriteAttributesRequest(resultIds.getObjectId(), attrSet); } break; - default: } if (request != null) { - this.sendRequest(registration, lwM2MClient, request, timeoutInMs); - } - else { + try { + this.sendRequest(registration, lwM2MClient, request, timeoutInMs); + } catch (ClientSleepingException e) { + DownlinkRequest finalRequest = request; + long finalTimeoutInMs = timeoutInMs; + lwM2MClient.getQueuedRequests().add(() -> sendRequest(registration, lwM2MClient, finalRequest, finalTimeoutInMs)); + } catch (Exception e) { + log.error("[{}] [{}] [{}] Failed to send downlink.", registration.getEndpoint(), targetIdVer, typeOper, e); + } + } else { log.error("[{}], [{}] - [{}] error SendRequest", registration.getEndpoint(), typeOper, targetIdVer); } } @@ -240,7 +247,7 @@ public class LwM2mTransportRequest { if (!lwM2MClient.isInit()) { lwM2MClient.initValue(this.serviceImpl, convertToIdVerFromObjectId(request.getPath().toString(), registration)); } - if (isSuccess(((Response) response.getCoapResponse()).getCode())) { + if (CoAP.ResponseCode.isSuccess(((Response) response.getCoapResponse()).getCode())) { this.handleResponse(registration, request.getPath().toString(), response, request); if (request instanceof WriteRequest && ((WriteRequest) request).isReplaceRequest()) { String msg = String.format("%s: sendRequest Replace: CoapCde - %s Lwm2m code - %d name - %s Resource path - %s value - %s SendRequest to Client", @@ -283,7 +290,7 @@ public class LwM2mTransportRequest { case FLOAT: // Double return (contentFormat == null) ? new WriteRequest(objectId, instanceId, resourceId, Double.parseDouble(value.toString())) : new WriteRequest(contentFormat, objectId, instanceId, resourceId, Double.parseDouble(value.toString())); case TIME: // Date - Date date = new Date(Long.decode(value.toString())); + Date date = new Date(Long.decode(value.toString())); return (contentFormat == null) ? new WriteRequest(objectId, instanceId, resourceId, date) : new WriteRequest(contentFormat, objectId, instanceId, resourceId, date); case OPAQUE: // byte[] value, base64 return (contentFormat == null) ? new WriteRequest(objectId, instanceId, resourceId, Hex.decodeHex(value.toString().toCharArray())) : new WriteRequest(contentFormat, objectId, instanceId, resourceId, Hex.decodeHex(value.toString().toCharArray())); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java index f8d2716808..96da8721c8 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java @@ -146,9 +146,9 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * Next -> Create new LwM2MClient for current session -> setModelClient... * * @param registration - Registration LwM2M Client - * @param previousObsersations - may be null + * @param previousObservations - may be null */ - public void onRegistered(Registration registration, Collection previousObsersations) { + public void onRegistered(Registration registration, Collection previousObservations) { executorRegistered.submit(() -> { try { log.warn("[{}] [{{}] Client: create after Registration", registration.getEndpoint(), registration.getId()); @@ -188,6 +188,13 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { LwM2mClient lwM2MClient = this.lwM2mClientContext.getLwM2MClient(sessionInfo); if (lwM2MClient.getDeviceId() == null && lwM2MClient.getProfileId() == null) { initLwM2mClient(lwM2MClient, sessionInfo); + } else { + if (registration.getBindingMode().useQueueMode()) { + LwM2mQueuedRequest request; + while ((request = lwM2MClient.getQueuedRequests().poll()) != null) { + request.send(); + } + } } log.info("Client: [{}] updatedReg [{}] name [{}] profile ", registration.getId(), registration.getEndpoint(), sessionInfo.getDeviceType()); @@ -206,7 +213,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * !!! Warn: if have not finishing unReg, then this operation will be finished on next Client`s connect */ public void unReg(Registration registration, Collection observations) { - executorUnRegistered.submit(() -> { + executorUnRegistered.submit(() -> { try { this.setCancelObservations(registration); this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client unRegistration", registration); @@ -239,8 +246,11 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { } } + @Override public void onSleepingDev(Registration registration) { log.info("[{}] [{}] Received endpoint Sleeping version event", registration.getId(), registration.getEndpoint()); + this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client is sleeping!", registration); + //TODO: associate endpointId with device information. } @@ -417,6 +427,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { */ protected void onAwakeDev(Registration registration) { log.info("[{}] [{}] Received endpoint Awake version event", registration.getId(), registration.getEndpoint()); + this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client is awake!", registration); //TODO: associate endpointId with device information. } @@ -612,7 +623,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { if (GET_TYPE_OPER_READ.equals(typeOper)) { result = JacksonUtil.fromString(lwM2MClientProfile.getPostAttributeProfile().toString(), new TypeReference<>() { }); - result.addAll(JacksonUtil.fromString(lwM2MClientProfile.getPostTelemetryProfile().toString(), new TypeReference<>() { + result.addAll(JacksonUtil.convertValue(lwM2MClientProfile.getPostTelemetryProfile().toString(), new TypeReference<>() { })); } else { result = JacksonUtil.fromString(lwM2MClientProfile.getPostObserveProfile().toString(), new TypeReference<>() { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index 186f085397..56e8fd5c7b 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -25,13 +25,16 @@ import org.eclipse.leshan.server.registration.Registration; import org.eclipse.leshan.server.security.SecurityInfo; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.transport.lwm2m.server.LwM2mQueuedRequest; import org.thingsboard.server.transport.lwm2m.server.LwM2mTransportServiceImpl; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; @@ -54,6 +57,7 @@ public class LwM2mClient implements Cloneable { private final Map resources; private final Map delayedRequests; private final List pendingRequests; + private final Queue queuedRequests; private boolean init; public Object clone() throws CloneNotSupportedException { @@ -71,6 +75,7 @@ public class LwM2mClient implements Cloneable { this.profileId = profileId; this.sessionId = sessionId; this.init = false; + this.queuedRequests = new ConcurrentLinkedQueue<>(); } public boolean saveResourceValue(String pathRez, LwM2mResource rez, LwM2mModelProvider modelProvider) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java index c6c545b574..f4eb71eaed 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java @@ -51,11 +51,11 @@ public abstract class AbstractComponentDescriptorInsertRepository implements Com TransactionStatus transaction = getTransactionStatus(TransactionDefinition.PROPAGATION_REQUIRES_NEW); try { componentDescriptorEntity = processSaveOrUpdate(entity, insertOrUpdateOnUniqueKeyConflict); + transactionManager.commit(transaction); } catch (Throwable th) { log.trace("Could not execute the update statement for Component Descriptor with id {}, name {} and entityType {}", entity.getUuid(), entity.getName(), entity.getType()); transactionManager.rollback(transaction); } - transactionManager.commit(transaction); } else { log.trace("Could not execute the insert statement for Component Descriptor with id {}, name {} and entityType {}", entity.getUuid(), entity.getName(), entity.getType()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java index fde9498702..93a33d1f9f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java @@ -51,11 +51,11 @@ public abstract class AbstractEventInsertRepository implements EventInsertReposi TransactionStatus transaction = getTransactionStatus(TransactionDefinition.PROPAGATION_REQUIRES_NEW); try { eventEntity = processSaveOrUpdate(entity, insertOrUpdateOnUniqueKeyConflict); + transactionManager.commit(transaction); } catch (Throwable th) { log.trace("Could not execute the update statement for Entity with entityId {} and entityType {}", entity.getEventUid(), entity.getEventType()); transactionManager.rollback(transaction); } - transactionManager.commit(transaction); } else { log.trace("Could not execute the insert statement for Entity with entityId {} and entityType {}", entity.getEventUid(), entity.getEventType()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index 7d09578dd1..240d5a0b88 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -62,6 +62,8 @@ import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.literal; @@ -107,6 +109,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement[] fetchStmtsDesc; private PreparedStatement deleteStmt; private PreparedStatement deletePartitionStmt; + private final Lock stmtCreationLock = new ReentrantLock(); private boolean isInstall() { return environment.acceptsProfiles(Profiles.of("install")); @@ -545,13 +548,20 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getDeleteStmt() { if (deleteStmt == null) { - deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF + - " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.TS_COLUMN + " >= ? " - + "AND " + ModelConstants.TS_COLUMN + " < ?"); + stmtCreationLock.lock(); + try { + if (deleteStmt == null) { + deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.TS_COLUMN + " >= ? " + + "AND " + ModelConstants.TS_COLUMN + " < ?"); + } + } finally { + stmtCreationLock.unlock(); + } } return deleteStmt; } @@ -585,27 +595,41 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getDeletePartitionStmt() { if (deletePartitionStmt == null) { - deletePartitionStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_PARTITIONS_CF + - " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM); + stmtCreationLock.lock(); + try { + if (deletePartitionStmt == null) { + deletePartitionStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_PARTITIONS_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM); + } + } finally { + stmtCreationLock.unlock(); + } } return deletePartitionStmt; } private PreparedStatement getSaveStmt(DataType dataType) { if (saveStmts == null) { - saveStmts = new PreparedStatement[DataType.values().length]; - for (DataType type : DataType.values()) { - saveStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.KEY_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.TS_COLUMN + - "," + getColumnName(type) + ")" + - " VALUES(?, ?, ?, ?, ?, ?)"); + stmtCreationLock.lock(); + try { + if (saveStmts == null) { + saveStmts = new PreparedStatement[DataType.values().length]; + for (DataType type : DataType.values()) { + saveStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.KEY_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.TS_COLUMN + + "," + getColumnName(type) + ")" + + " VALUES(?, ?, ?, ?, ?, ?)"); + } + } + } finally { + stmtCreationLock.unlock(); } } return saveStmts[dataType.ordinal()]; @@ -613,16 +637,23 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getSaveTtlStmt(DataType dataType) { if (saveTtlStmts == null) { - saveTtlStmts = new PreparedStatement[DataType.values().length]; - for (DataType type : DataType.values()) { - saveTtlStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.KEY_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.TS_COLUMN + - "," + getColumnName(type) + ")" + - " VALUES(?, ?, ?, ?, ?, ?) USING TTL ?"); + stmtCreationLock.lock(); + try { + if (saveTtlStmts == null) { + saveTtlStmts = new PreparedStatement[DataType.values().length]; + for (DataType type : DataType.values()) { + saveTtlStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.KEY_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.TS_COLUMN + + "," + getColumnName(type) + ")" + + " VALUES(?, ?, ?, ?, ?, ?) USING TTL ?"); + } + } + } finally { + stmtCreationLock.unlock(); } } return saveTtlStmts[dataType.ordinal()]; @@ -630,24 +661,38 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getPartitionInsertStmt() { if (partitionInsertStmt == null) { - partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.KEY_COLUMN + ")" + - " VALUES(?, ?, ?, ?)"); + stmtCreationLock.lock(); + try { + if (partitionInsertStmt == null) { + partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.KEY_COLUMN + ")" + + " VALUES(?, ?, ?, ?)"); + } + } finally { + stmtCreationLock.unlock(); + } } return partitionInsertStmt; } private PreparedStatement getPartitionInsertTtlStmt() { if (partitionInsertTtlStmt == null) { - partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.KEY_COLUMN + ")" + - " VALUES(?, ?, ?, ?) USING TTL ?"); + stmtCreationLock.lock(); + try { + if (partitionInsertTtlStmt == null) { + partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.KEY_COLUMN + ")" + + " VALUES(?, ?, ?, ?) USING TTL ?"); + } + } finally { + stmtCreationLock.unlock(); + } } return partitionInsertTtlStmt; } @@ -713,12 +758,26 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD switch (orderBy) { case ASC_ORDER: if (fetchStmtsAsc == null) { - fetchStmtsAsc = initFetchStmt(orderBy); + stmtCreationLock.lock(); + try { + if (fetchStmtsAsc == null) { + fetchStmtsAsc = initFetchStmt(orderBy); + } + } finally { + stmtCreationLock.unlock(); + } } return fetchStmtsAsc[aggType.ordinal()]; case DESC_ORDER: if (fetchStmtsDesc == null) { - fetchStmtsDesc = initFetchStmt(orderBy); + stmtCreationLock.lock(); + try { + if (fetchStmtsDesc == null) { + fetchStmtsDesc = initFetchStmt(orderBy); + } + } finally { + stmtCreationLock.unlock(); + } } return fetchStmtsDesc[aggType.ordinal()]; default: diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index 044fe00255..98718a201b 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -10,6 +10,7 @@ To run the black box tests with using Docker, the local Docker images of Thingsb As result, in REPOSITORY column, next images should be present: thingsboard/tb-coap-transport + thingsboard/tb-lwm2m-transport thingsboard/tb-http-transport thingsboard/tb-mqtt-transport thingsboard/tb-node diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index f9eddd420c..ec92116c81 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -17,15 +17,14 @@ FROM thingsboard/openjdk11 RUN apt-get update -RUN apt-get install -y curl nmap procps -RUN echo 'deb http://ftp.us.debian.org/debian sid main' | tee --append /etc/apt/sources.list.d/debian.list > /dev/null -RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ sid-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null +RUN apt-get install -y curl nmap procps gnupg2 +RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ buster-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null RUN curl -L https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - RUN echo 'deb http://downloads.apache.org/cassandra/debian 40x main' | tee --append /etc/apt/sources.list.d/cassandra.list > /dev/null RUN curl -L https://downloads.apache.org/cassandra/KEYS | apt-key add - -ENV PG_MAJOR=11 +ENV PG_MAJOR=12 RUN apt-get update -RUN apt-get install -y cassandra cassandra-tools postgresql-11 +RUN apt-get install -y cassandra cassandra-tools postgresql-12 RUN update-rc.d cassandra disable RUN update-rc.d postgresql disable RUN sed -i.old '/ulimit/d' /etc/init.d/cassandra diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile index 9b314a5a5d..7221bc08c6 100644 --- a/msa/tb/docker-postgres/Dockerfile +++ b/msa/tb/docker-postgres/Dockerfile @@ -17,13 +17,12 @@ FROM thingsboard/openjdk11 RUN apt-get update -RUN apt-get install -y curl -RUN echo 'deb http://ftp.us.debian.org/debian sid main' | tee --append /etc/apt/sources.list.d/debian.list > /dev/null -RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ sid-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null +RUN apt-get install -y curl gnupg2 +RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ buster-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null RUN curl -L https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -ENV PG_MAJOR 11 +ENV PG_MAJOR 12 RUN apt-get update -RUN apt-get install -y postgresql-11 +RUN apt-get install -y postgresql-12 RUN update-rc.d postgresql disable COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index efb9a709e9..a19c2c542f 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -39,7 +39,7 @@ tb-postgres tb-cassandra /usr/share/${pkg.name} - 2.4.2 + 3.3.0 diff --git a/pom.xml b/pom.xml index c26964e8e9..d954128e96 100755 --- a/pom.xml +++ b/pom.xml @@ -99,7 +99,7 @@ org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* 5.0.2 - 0.1.31 + 0.1.16 2.6.0 4.1.1 2.57 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 de7ba29141..815897faaa 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 @@ -1338,7 +1338,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { HttpEntity.EMPTY, DeviceProfile.class, deviceProfileId).getBody(); } - public PageData getTenantDevices(PageLink pageLink) { + public PageData getDeviceProfiles(PageLink pageLink) { Map params = new HashMap<>(); addPageLinkToParam(params, pageLink); return restTemplate.exchange( diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index d8bc18291b..6d5a367c86 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -18,10 +18,10 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { Resource, ResourceInfo } from '@shared/models/resource.models'; -import { map } from 'rxjs/operators'; +import { catchError, map, mergeMap } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -70,6 +70,25 @@ export class ResourceService { ); } + public saveResources(resources: Resource[], config?: RequestConfig): Observable { + let partSize = 100; + partSize = resources.length > partSize ? partSize : resources.length; + const resourceObservables: Observable[] = []; + for (let i = 0; i < partSize; i++) { + resourceObservables.push(this.saveResource(resources[i], config).pipe(catchError(() => of({} as Resource)))); + } + return forkJoin(resourceObservables).pipe( + mergeMap((resource) => { + resources.splice(0, partSize); + if (resources.length) { + return this.saveResources(resources, config); + } else { + return of(resource); + } + }) + ); + } + public saveResource(resource: Resource, config?: RequestConfig): Observable { return this.http.post('/api/resource', resource, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/core/services/time.service.ts b/ui-ngx/src/app/core/services/time.service.ts index 47c8d8a456..410b150e48 100644 --- a/ui-ngx/src/app/core/services/time.service.ts +++ b/ui-ngx/src/app/core/services/time.service.ts @@ -79,7 +79,7 @@ export class TimeService { public boundMinInterval(min: number): number { if (isDefined(min)) { - min = Math.floor(min / 1000) * 1000; + min = Math.ceil(min / 1000) * 1000; } return this.toBound(min, MIN_INTERVAL, MAX_INTERVAL, MIN_INTERVAL); } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index b9c2697d0a..225a1398b0 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -127,6 +127,10 @@ export function isEmpty(obj: any): boolean { return true; } +export function isLiteralObject(value: any) { + return (!!value) && (value.constructor === Object); +} + export function formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined { if (isDefinedAndNotNull(value) && isNumeric(value) && (isDefinedAndNotNull(dec) || isDefinedAndNotNull(units) || Number(value).toString() === value)) { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 7e760e3337..f8770b9adc 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -90,7 +90,6 @@ import { } from '@home/components/alias/entity-aliases-dialog.component'; import { EntityAliases } from '@app/shared/models/alias.models'; import { EditWidgetComponent } from '@home/components/dashboard-page/edit-widget.component'; -import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { AddWidgetDialogComponent, AddWidgetDialogData @@ -118,8 +117,7 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { DISPLAY_WIDGET_TYPES_PANEL_DATA, DisplayWidgetTypesPanelComponent, - DisplayWidgetTypesPanelData, - WidgetTypes + DisplayWidgetTypesPanelData } from '@home/components/dashboard-page/widget-types-panel.component'; import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; import {AliasEntityType, EntityType} from "@shared/models/entity-type.models"; @@ -1189,13 +1187,16 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC overlayRef.dispose(); }); + const filterWidgetTypes = this.dashboardWidgetSelectComponent.filterWidgetTypes; + const widgetTypesList = Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => { + return {type, display: filterWidgetTypes === null ? true : filterWidgetTypes.includes(type)}; + }); + const providers: StaticProvider[] = [ { provide: DISPLAY_WIDGET_TYPES_PANEL_DATA, useValue: { - types: Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => { - return {type, display: true}; - }), + types: widgetTypesList, typesUpdated: (newTypes) => { this.filterWidgetTypes = newTypes.filter(type => type.display).map(type => type.type); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index c51cc9c37f..645d955deb 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -77,6 +77,10 @@ export class DashboardWidgetSelectComponent implements OnInit { this.filterWidgetTypes$.next(widgetTypes); } + get filterWidgetTypes(): Array { + return this.filterWidgetTypes$.value; + } + @Output() widgetSelected: EventEmitter = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index d893ec848f..c050b524cf 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -80,7 +80,7 @@ (mousedown)="widgetMouseDown($event, widget)" (click)="widgetClicked($event, widget)" (contextmenu)="openWidgetContextMenu($event, widget)"> -
+
BooleanFilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => BooleanFilterPredicateComponent), + multi: true } ] }) -export class BooleanFilterPredicateComponent implements ControlValueAccessor, OnInit { +export class BooleanFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -73,7 +88,7 @@ export class BooleanFilterPredicateComponent implements ControlValueAccessor, On registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.booleanFilterPredicateFormGroup.disable({emitEvent: false}); @@ -82,17 +97,20 @@ export class BooleanFilterPredicateComponent implements ControlValueAccessor, On } } + validate(): ValidationErrors | null { + return this.booleanFilterPredicateFormGroup ? null : { + booleanFilterPredicate: {valid: false} + }; + } + writeValue(predicate: BooleanFilterPredicate): void { this.booleanFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); this.booleanFilterPredicateFormGroup.get('value').patchValue(predicate.value, {emitEvent: false}); } private updateModel() { - let predicate: BooleanFilterPredicate = null; - if (this.booleanFilterPredicateFormGroup.valid) { - predicate = this.booleanFilterPredicateFormGroup.getRawValue(); - predicate.type = FilterPredicateType.BOOLEAN; - } + const predicate: BooleanFilterPredicate = this.booleanFilterPredicateFormGroup.getRawValue(); + predicate.type = FilterPredicateType.BOOLEAN; this.propagateChange(predicate); } diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts index 9f4acfc3c6..b177f118c0 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts @@ -16,11 +16,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { - ComplexFilterPredicate, - ComplexFilterPredicateInfo, - EntityKeyValueType -} from '@shared/models/query/query.models'; +import { ComplexFilterPredicateInfo, EntityKeyValueType } from '@shared/models/query/query.models'; import { MatDialog } from '@angular/material/dialog'; import { ComplexFilterPredicateDialogComponent, @@ -71,7 +67,7 @@ export class ComplexFilterPredicateComponent implements ControlValueAccessor, On registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts index 3c16b01319..75c9bd3ce8 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts @@ -21,7 +21,10 @@ import { FormArray, FormBuilder, FormGroup, + NG_VALIDATORS, NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, Validators } from '@angular/forms'; import { Observable, of, Subscription } from 'rxjs'; @@ -49,10 +52,15 @@ import { map } from 'rxjs/operators'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FilterPredicateListComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateListComponent), + multi: true } ] }) -export class FilterPredicateListComponent implements ControlValueAccessor, OnInit { +export class FilterPredicateListComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -108,6 +116,12 @@ export class FilterPredicateListComponent implements ControlValueAccessor, OnIni } } + validate(control: AbstractControl): ValidationErrors | null { + return this.filterListFormGroup.valid ? null : { + filterList: {valid: false} + }; + } + writeValue(predicates: Array): void { if (this.valueChangeSubscription) { this.valueChangeSubscription.unsubscribe(); @@ -178,7 +192,7 @@ export class FilterPredicateListComponent implements ControlValueAccessor, OnIni private updateModel() { const predicates: Array = this.filterListFormGroup.getRawValue().predicates; - if (this.filterListFormGroup.valid && predicates.length) { + if (predicates.length) { this.propagateChange(predicates); } else { this.propagateChange(null); diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts index 7417317dae..934a4774fb 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts @@ -19,7 +19,10 @@ import { ControlValueAccessor, FormBuilder, FormGroup, + NG_VALIDATORS, NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, ValidatorFn, Validators } from '@angular/forms'; @@ -39,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FilterPredicateValueComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateValueComponent), + multi: true } ] }) -export class FilterPredicateValueComponent implements ControlValueAccessor, OnInit { +export class FilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit { private readonly inheritModeForSources: DynamicValueSourceType[] = [ DynamicValueSourceType.CURRENT_CUSTOMER, @@ -62,7 +70,22 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn } } - @Input() onlyUserDynamicSource = false; + private onlyUserDynamicSourceValue = false; + + @Input() + set onlyUserDynamicSource(dynamicMode: boolean) { + this.onlyUserDynamicSourceValue = dynamicMode; + if (this.filterPredicateValueFormGroup) { + this.updateValidationDynamicMode(); + setTimeout(() => { + this.updateModel(); + }, 0); + } + } + + get onlyUserDynamicSource(): boolean { + return this.onlyUserDynamicSourceValue; + } @Input() valueType: EntityKeyValueType; @@ -83,6 +106,7 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn allow = true; private propagateChange = null; + private propagateChangePending = false; constructor(private fb: FormBuilder) { } @@ -126,6 +150,7 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn this.updateShowInheritMode(sourceType); } ); + this.updateValidationDynamicMode(); this.filterPredicateValueFormGroup.valueChanges.subscribe(() => { this.updateModel(); }); @@ -133,12 +158,18 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn registerOnChange(fn: any): void { this.propagateChange = fn; + if (this.propagateChangePending) { + this.propagateChangePending = false; + setTimeout(() => { + this.updateModel(); + }, 0); + } } registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.filterPredicateValueFormGroup.disable({emitEvent: false}); @@ -147,28 +178,35 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn } } + validate(): ValidationErrors | null { + return this.filterPredicateValueFormGroup.valid ? null : { + filterPredicateValue: {valid: false} + }; + } + writeValue(predicateValue: FilterPredicateValue): void { + this.propagateChangePending = false; this.filterPredicateValueFormGroup.get('defaultValue').patchValue(predicateValue.defaultValue, {emitEvent: false}); - this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').patchValue(predicateValue.dynamicValue ? - predicateValue.dynamicValue.sourceType : null, {emitEvent: false}); - this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').patchValue(predicateValue.dynamicValue ? - predicateValue.dynamicValue.sourceAttribute : null, {emitEvent: false}); - this.filterPredicateValueFormGroup.get('dynamicValue.inherit').patchValue(predicateValue.dynamicValue ? - predicateValue.dynamicValue.inherit : false, {emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValue').patchValue({ + sourceType: predicateValue.dynamicValue ? predicateValue.dynamicValue.sourceType : null, + sourceAttribute: predicateValue.dynamicValue ? predicateValue.dynamicValue.sourceAttribute : null, + inherit: predicateValue.dynamicValue ? predicateValue.dynamicValue.inherit : false + }, {emitEvent: this.onlyUserDynamicSource}); this.updateShowInheritMode(predicateValue?.dynamicValue?.sourceType); } private updateModel() { - let predicateValue: FilterPredicateValue = null; - if (this.filterPredicateValueFormGroup.valid) { - predicateValue = this.filterPredicateValueFormGroup.getRawValue(); - if (predicateValue.dynamicValue) { - if (!predicateValue.dynamicValue.sourceType || !predicateValue.dynamicValue.sourceAttribute) { - predicateValue.dynamicValue = null; - } + const predicateValue: FilterPredicateValue = this.filterPredicateValueFormGroup.getRawValue(); + if (predicateValue.dynamicValue) { + if (!predicateValue.dynamicValue.sourceType || !predicateValue.dynamicValue.sourceAttribute) { + predicateValue.dynamicValue = null; } } - this.propagateChange(predicateValue); + if (this.propagateChange) { + this.propagateChange(predicateValue); + } else { + this.propagateChangePending = true; + } } private updateShowInheritMode(sourceType: DynamicValueSourceType) { @@ -179,4 +217,16 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn this.inheritMode = false; } } + + private updateValidationDynamicMode() { + if (this.onlyUserDynamicSource) { + this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').setValidators(Validators.required); + this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').setValidators(Validators.required); + } else { + this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').clearValidators(); + this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').clearValidators(); + } + this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').updateValueAndValidity({emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').updateValueAndValidity({emitEvent: false}); + } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts index 69972b6a90..17361f55bd 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts @@ -15,11 +15,17 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { - EntityKeyValueType, - FilterPredicateType, KeyFilterPredicate, KeyFilterPredicateInfo -} from '@shared/models/query/query.models'; + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityKeyValueType, FilterPredicateType, KeyFilterPredicateInfo } from '@shared/models/query/query.models'; @Component({ selector: 'tb-filter-predicate', @@ -30,10 +36,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateComponent), + multi: true } ] }) -export class FilterPredicateComponent implements ControlValueAccessor, OnInit { +export class FilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -75,7 +86,7 @@ export class FilterPredicateComponent implements ControlValueAccessor, OnInit { registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.filterPredicateFormGroup.disable({emitEvent: false}); @@ -84,6 +95,12 @@ export class FilterPredicateComponent implements ControlValueAccessor, OnInit { } } + validate(): ValidationErrors | null { + return this.filterPredicateFormGroup.valid ? null : { + filterPredicate: {valid: false} + }; + } + writeValue(predicate: KeyFilterPredicateInfo): void { this.type = predicate.keyFilterPredicate.type; this.filterPredicateFormGroup.get('predicate').patchValue(predicate.keyFilterPredicate, {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts index 1229f18ec2..c1d1a1db82 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts @@ -22,7 +22,10 @@ import { FormBuilder, FormControl, FormGroup, + NG_VALIDATORS, NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, Validators } from '@angular/forms'; import { Observable, Subscription } from 'rxjs'; @@ -46,10 +49,15 @@ import { EntityId } from '@shared/models/id/entity-id'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => KeyFilterListComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KeyFilterListComponent), + multi: true } ] }) -export class KeyFilterListComponent implements ControlValueAccessor, OnInit { +export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -104,6 +112,12 @@ export class KeyFilterListComponent implements ControlValueAccessor, OnInit { } } + validate(): ValidationErrors | null { + return this.keyFilterListFormGroup.valid && this.keyFiltersControl.valid ? null : { + keyFilterList: {valid: false} + }; + } + writeValue(keyFilters: Array): void { if (this.valueChangeSubscription) { this.valueChangeSubscription.unsubscribe(); diff --git a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts index 434c90fa80..2b24a5a6b1 100644 --- a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts @@ -15,7 +15,16 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; import { EntityKeyValueType, FilterPredicateType, @@ -33,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NumericFilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NumericFilterPredicateComponent), + multi: true } ] }) -export class NumericFilterPredicateComponent implements ControlValueAccessor, OnInit { +export class NumericFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -76,7 +90,7 @@ export class NumericFilterPredicateComponent implements ControlValueAccessor, On registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.numericFilterPredicateFormGroup.disable({emitEvent: false}); @@ -85,17 +99,20 @@ export class NumericFilterPredicateComponent implements ControlValueAccessor, On } } + validate(): ValidationErrors | null { + return this.numericFilterPredicateFormGroup.valid ? null : { + numericFilterPredicate: {valid: false} + }; + } + writeValue(predicate: NumericFilterPredicate): void { this.numericFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); this.numericFilterPredicateFormGroup.get('value').patchValue(predicate.value, {emitEvent: false}); } private updateModel() { - let predicate: NumericFilterPredicate = null; - if (this.numericFilterPredicateFormGroup.valid) { - predicate = this.numericFilterPredicateFormGroup.getRawValue(); - predicate.type = FilterPredicateType.NUMERIC; - } + const predicate: NumericFilterPredicate = this.numericFilterPredicateFormGroup.getRawValue(); + predicate.type = FilterPredicateType.NUMERIC; this.propagateChange(predicate); } diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts index 11b4dad275..3caa282576 100644 --- a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts @@ -15,7 +15,16 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; import { EntityKeyValueType, FilterPredicateType, @@ -33,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StringFilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => StringFilterPredicateComponent), + multi: true } ] }) -export class StringFilterPredicateComponent implements ControlValueAccessor, OnInit { +export class StringFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -90,12 +104,15 @@ export class StringFilterPredicateComponent implements ControlValueAccessor, OnI this.stringFilterPredicateFormGroup.get('ignoreCase').patchValue(predicate.ignoreCase, {emitEvent: false}); } + validate(c): ValidationErrors { + return this.stringFilterPredicateFormGroup.valid ? null : { + stringFilterPredicate: {valid: false} + }; + } + private updateModel() { - let predicate: StringFilterPredicate = null; - if (this.stringFilterPredicateFormGroup.valid) { - predicate = this.stringFilterPredicateFormGroup.getRawValue(); - predicate.type = FilterPredicateType.STRING; - } + const predicate: StringFilterPredicate = this.stringFilterPredicateFormGroup.getRawValue(); + predicate.type = FilterPredicateType.STRING; this.propagateChange(predicate); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html new file mode 100644 index 0000000000..81c06fe0f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html @@ -0,0 +1,69 @@ + + +
+
+
+
+ +
+
+ + +
+
+ +
+
+ {{ 'widgets.input-widgets.no-entity-selected' | translate }} +
+
+ {{ 'widgets.input-widgets.no-datakey-selected' | translate }} +
+
+ {{ errorMessage | translate }} +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss new file mode 100644 index 0000000000..f097de5afb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2021 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. + */ + +.tb-json-input { + width: 100%; + height: 100%; + padding: 5px; + + &__form { + overflow: auto; + height: 100%; + } + + &__error { + text-align: center; + font-size: 18px; + color: #a0a0a0; + } +} + +.tb-toast { + font-size: 14px!important; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts new file mode 100644 index 0000000000..77c0f907ce --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts @@ -0,0 +1,227 @@ +/// +/// Copyright © 2016-2021 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Datasource, DatasourceData, DatasourceType, WidgetConfig } from '@shared/models/widget.models'; +import { IWidgetSubscription } from '@core/api/widget-api.models'; +import { FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { AttributeService } from '@core/http/attribute.service'; +import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; +import { createLabelFromDatasource } from '@core/utils'; +import { Observable } from 'rxjs'; + +enum JsonInputWidgetMode { + ATTRIBUTE = 'ATTRIBUTE', + TIME_SERIES = 'TIME_SERIES', +} + +interface JsonInputWidgetSettings { + widgetTitle: string; + widgetMode: JsonInputWidgetMode; + attributeScope?: AttributeScope; + showLabel: boolean; + labelValue?: string; + attributeRequired: boolean; + showResultMessage: boolean; +} + +@Component({ + selector: 'tb-json-input-widget ', + templateUrl: './json-input-widget.component.html', + styleUrls: ['./json-input-widget.component.scss'] +}) +export class JsonInputWidgetComponent extends PageComponent implements OnInit { + + @Input() + ctx: WidgetContext; + + public settings: JsonInputWidgetSettings; + private widgetConfig: WidgetConfig; + private subscription: IWidgetSubscription; + private datasource: Datasource; + + labelValue: string; + + entityDetected = false; + dataKeyDetected = false; + isValidParameter = false; + errorMessage: string; + + isFocused: boolean; + originalValue: any; + attributeUpdateFormGroup: FormGroup; + + toastTargetId = 'json-input-widget' + this.utils.guid(); + + constructor(protected store: Store, + private utils: UtilsService, + private fb: FormBuilder, + private attributeService: AttributeService, + private translate: TranslateService) { + super(store); + } + + ngOnInit(): void { + this.ctx.$scope.jsonInputWidget = this; + this.settings = this.ctx.settings; + this.widgetConfig = this.ctx.widgetConfig; + this.subscription = this.ctx.defaultSubscription; + this.datasource = this.subscription.datasources[0]; + this.initializeConfig(); + this.validateDatasources(); + this.buildForm(); + this.ctx.updateWidgetParams(); + } + + private initializeConfig() { + if (this.settings.widgetTitle && this.settings.widgetTitle.length) { + const title = createLabelFromDatasource(this.datasource, this.settings.widgetTitle); + this.ctx.widgetTitle = this.utils.customTranslation(title, title); + } else { + this.ctx.widgetTitle = this.ctx.widgetConfig.title; + } + + if (this.settings.labelValue && this.settings.labelValue.length) { + const label = createLabelFromDatasource(this.datasource, this.settings.labelValue); + this.labelValue = this.utils.customTranslation(label, label); + } else { + this.labelValue = this.translate.instant('widgets.input-widgets.value'); + } + } + + private validateDatasources() { + if (this.datasource?.type === DatasourceType.entity) { + this.entityDetected = true; + if (this.datasource.dataKeys.length) { + this.dataKeyDetected = true; + + if (this.settings.widgetMode === JsonInputWidgetMode.ATTRIBUTE) { + if (this.datasource.dataKeys[0].type === DataKeyType.attribute) { + if (this.settings.attributeScope === AttributeScope.SERVER_SCOPE || this.datasource.entityType === EntityType.DEVICE) { + this.isValidParameter = true; + } else { + this.errorMessage = 'widgets.input-widgets.not-allowed-entity'; + } + } else { + this.errorMessage = 'widgets.input-widgets.no-attribute-selected'; + } + } else { + if (this.datasource.dataKeys[0].type === DataKeyType.timeseries) { + this.isValidParameter = true; + } else { + this.errorMessage = 'widgets.input-widgets.no-timeseries-selected'; + } + } + + } + } + } + + private buildForm() { + const validators: ValidatorFn[] = []; + if (this.settings.attributeRequired) { + validators.push(Validators.required); + } + this.attributeUpdateFormGroup = this.fb.group({ + currentValue: [{}, validators] + }); + this.attributeUpdateFormGroup.valueChanges.subscribe( () => { + this.ctx.detectChanges(); + }); + } + + private updateWidgetData(data: Array) { + if (this.isValidParameter) { + let value = {}; + if (data[0].data[0][1] !== '') { + try { + value = JSON.parse(data[0].data[0][1]); + } catch (e) { + value = data[0].data[0][1]; + } + } + this.originalValue = value; + if (!this.isFocused) { + this.attributeUpdateFormGroup.get('currentValue').patchValue(this.originalValue); + this.ctx.detectChanges(); + } + } + } + + public onDataUpdated() { + this.updateWidgetData(this.subscription.data); + } + + public save() { + this.isFocused = false; + + const attributeToSave: AttributeData = { + key: this.datasource.dataKeys[0].name, + value: this.attributeUpdateFormGroup.get('currentValue').value + }; + + const entityId: EntityId = { + entityType: this.datasource.entityType, + id: this.datasource.entityId + }; + + let saveAttributeObservable: Observable; + if (this.settings.widgetMode === JsonInputWidgetMode.ATTRIBUTE) { + saveAttributeObservable = this.attributeService.saveEntityAttributes( + entityId, + this.settings.attributeScope, + [ attributeToSave ], + {} + ); + } else { + saveAttributeObservable = this.attributeService.saveEntityTimeseries( + entityId, + LatestTelemetry.LATEST_TELEMETRY, + [ attributeToSave ], + {} + ); + } + saveAttributeObservable.subscribe( + () => { + this.attributeUpdateFormGroup.markAsPristine(); + this.ctx.detectChanges(); + if (this.settings.showResultMessage) { + this.ctx.showSuccessToast(this.translate.instant('widgets.input-widgets.update-successful'), + 1000, 'bottom', 'left', this.toastTargetId); + } + }, + () => { + if (this.settings.showResultMessage) { + this.ctx.showErrorToast(this.translate.instant('widgets.input-widgets.update-failed'), + 'bottom', 'left', this.toastTargetId); + } + }); + } + + public discard() { + this.attributeUpdateFormGroup.reset({currentValue: this.originalValue}, {emitEvent: false}); + this.attributeUpdateFormGroup.markAsPristine(); + this.isFocused = false; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts index 077d8dfb6d..32a9074ca0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts @@ -19,7 +19,8 @@ import { createLabelFromDatasource, hashCode, isDefined, - isDefinedAndNotNull, isFunction, + isDefinedAndNotNull, + isFunction, isNumber, isUndefined, padValue @@ -30,7 +31,7 @@ import { Datasource, DatasourceData } from '@shared/models/widget.models'; import _ from 'lodash'; import { mapProviderSchema, providerSets } from '@home/components/widget/lib/maps/schemes'; import { addCondition, mergeSchemes } from '@core/schema-utils'; -import L, {Projection} from "leaflet"; +import L from 'leaflet'; export function getProviderSchema(mapProvider: MapProviders, ignoreImageMap = false) { const providerSchema = _.cloneDeep(mapProviderSchema); @@ -318,22 +319,24 @@ export const parseWithTranslation = { }; export function parseData(input: DatasourceData[]): FormattedData[] { - return _(input).groupBy(el => el?.datasource?.entityName) + return _(input).groupBy(el => el?.datasource.entityId + el?.datasource.entityType) .values().value().map((entityArray, i) => { const obj: FormattedData = { entityName: entityArray[0]?.datasource?.entityName, - entityId: entityArray[0]?.datasource?.entityId, - entityType: entityArray[0]?.datasource?.entityType, - $datasource: entityArray[0]?.datasource, + entityId: entityArray[0].datasource.entityId, + entityType: entityArray[0].datasource.entityType, + $datasource: entityArray[0].datasource, dsIndex: i, deviceType: null }; entityArray.filter(el => el.data.length).forEach(el => { - const indexDate = el?.data?.length ? el.data.length - 1 : 0; - obj[el?.dataKey?.label] = el?.data[indexDate][1]; - obj[el?.dataKey?.label + '|ts'] = el?.data[indexDate][0]; - if (el?.dataKey?.label === 'type') { - obj.deviceType = el?.data[indexDate][1]; + const indexDate = el.data.length ? el.data.length - 1 : 0; + if (!obj.hasOwnProperty(el.dataKey.label) || el.data[indexDate][1] !== '') { + obj[el.dataKey.label] = el.data[indexDate][1]; + obj[el.dataKey.label + '|ts'] = el.data[indexDate][0]; + if (el.dataKey.label === 'type') { + obj.deviceType = el.data[indexDate][1]; + } } }); return obj; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts index b1b5a3b336..d2cf53265f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts @@ -121,7 +121,7 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy this.switchResize$ = new ResizeObserver(() => { this.resize(); - }) + }); this.switchResize$.observe(this.switchContainerRef.nativeElement); this.init(); } @@ -202,13 +202,13 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy let width = this.switchContainer.width(); let height = this.switchContainer.height(); if (this.showOnOffLabels) { - height = height*2/3; + height = height * 2 / 3; } - const ratio = width/height; + const ratio = width / height; if (ratio > switchAspectRation) { - width = height*switchAspectRation; + width = height * switchAspectRation; } else { - height = width/switchAspectRation; + height = width / switchAspectRation; } this.switchElement.css({width, height}); this.matSlideToggle.css({width, height, minWidth: width}); @@ -232,11 +232,11 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy fontSize--; textWidth = this.measureTextWidth(text, fontSize); } - element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + element.css({fontSize: fontSize + 'px', lineHeight: fontSize + 'px'}); } private measureTextWidth(text: string, fontSize: number): number { - this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + this.textMeasure.css({fontSize: fontSize + 'px', lineHeight: fontSize + 'px'}); this.textMeasure.text(text); return this.textMeasure.width(); } @@ -260,6 +260,7 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( (responseBody) => { this.setValue(this.parseValueFunction(responseBody)); + this.ctx.detectChanges(); }, () => { const errorText = this.ctx.defaultSubscription.rpcErrorText; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 2ad0fb1612..801ec12dc7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -38,6 +38,7 @@ import { ImportExportService } from '@home/components/import-export/import-expor import { NavigationCardsWidgetComponent } from '@home/components/widget/lib/navigation-cards-widget.component'; import { NavigationCardWidgetComponent } from '@home/components/widget/lib/navigation-card-widget.component'; import { EdgesOverviewWidgetComponent } from '@home/components/widget/lib/edges-overview-widget.component'; +import { JsonInputWidgetComponent } from '@home/components/widget/lib/json-input-widget.component'; @NgModule({ declarations: @@ -51,6 +52,7 @@ import { EdgesOverviewWidgetComponent } from '@home/components/widget/lib/edges- EdgesOverviewWidgetComponent, DateRangeNavigatorWidgetComponent, DateRangeNavigatorPanelComponent, + JsonInputWidgetComponent, MultipleInputWidgetComponent, TripAnimationComponent, PhotoCameraInputWidgetComponent, @@ -72,6 +74,7 @@ import { EdgesOverviewWidgetComponent } from '@home/components/widget/lib/edges- EdgesOverviewWidgetComponent, RpcWidgetsModule, DateRangeNavigatorWidgetComponent, + JsonInputWidgetComponent, MultipleInputWidgetComponent, TripAnimationComponent, PhotoCameraInputWidgetComponent, diff --git a/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts index 57f4a18205..4ea3a459cd 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts @@ -104,7 +104,7 @@ export class AssetsTableConfigResolver implements Resolve this.onAssetAction(action); - this.config.detailsReadonly = () => this.config.componentsData.assetScope === 'customer_user'; + this.config.detailsReadonly = () => (this.config.componentsData.assetScope === 'customer_user' || this.config.componentsData.assetScope === 'edge_customer_user'); this.config.headerComponent = AssetTableHeaderComponent; @@ -151,7 +151,7 @@ export class AssetsTableConfigResolver implements Resolve this.config.componentsData.assetScope === 'tenant'; return this.config; diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts index 61422f1fa3..0a85c81375 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts @@ -103,7 +103,7 @@ export class DashboardsTableConfigResolver implements Resolve this.onDashboardAction(action); - this.config.detailsReadonly = () => this.config.componentsData.dashboardScope === 'customer_user'; + this.config.detailsReadonly = () => (this.config.componentsData.dashboardScope === 'customer_user' || this.config.componentsData.dashboardScope === 'edge_customer_user'); } resolve(route: ActivatedRouteSnapshot): Observable> { @@ -147,7 +147,7 @@ export class DashboardsTableConfigResolver implements Resolve this.config.componentsData.dashboardScope === 'tenant'; return this.config; diff --git a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts index 1349ffae1e..36b93fa697 100644 --- a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts @@ -112,7 +112,7 @@ export class DevicesTableConfigResolver implements Resolve this.onDeviceAction(action); - this.config.detailsReadonly = () => this.config.componentsData.deviceScope === 'customer_user'; + this.config.detailsReadonly = () => (this.config.componentsData.deviceScope === 'customer_user' || this.config.componentsData.deviceScope === 'edge_customer_user'); this.config.headerComponent = DeviceTableHeaderComponent; @@ -161,7 +161,7 @@ export class DevicesTableConfigResolver implements Resolve this.config.componentsData.deviceScope === 'tenant'; return this.config; diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts index da5aedf53b..c2eff33a11 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts @@ -58,21 +58,6 @@ const routes: Routes = [ entitiesTableConfig: EdgesTableConfigResolver } }, - { - path: ':edgeId/ruleChains', - component: EntitiesTableComponent, - data: { - auth: [Authority.TENANT_ADMIN], - ruleChainsType: 'edge', - breadcrumb: { - label: 'edge.edge-rulechains', - icon: 'settings_ethernet' - }, - }, - resolve: { - entitiesTableConfig: RuleChainsTableConfigResolver - } - }, { path: ':edgeId/assets', component: EntitiesTableComponent, @@ -156,6 +141,50 @@ const routes: Routes = [ } ] }, + { + path: ':edgeId/ruleChains', + data: { + breadcrumb: { + label: 'edge.edge-rulechains', + icon: 'settings_ethernet' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'edge.rulechains', + ruleChainsType: 'edge' + }, + resolve: { + entitiesTableConfig: RuleChainsTableConfigResolver + } + }, + { + path: ':ruleChainId', + component: RuleChainPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: ruleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.edge-rulechain', + import: false, + ruleChainType: RuleChainType.EDGE + }, + resolve: { + ruleChain: RuleChainResolver, + ruleChainMetaData: ResolvedRuleChainMetaDataResolver, + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + } + ] + }, { path: 'ruleChains', data: { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html index 3177c5194f..6ae548acb2 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html @@ -44,31 +44,31 @@
@@ -85,7 +85,7 @@ ngxClipboard (cbOnSuccess)="onEdgeInfoCopied('key')" [cbContent]="entity?.routingKey" - [fxShow]="!isEdit && (edgeScope === 'tenant' || edgeScope === 'customer')"> + [fxShow]="!isEdit && edgeScope !== 'customer_user'"> edge.copy-edge-key @@ -93,7 +93,7 @@ ngxClipboard (cbOnSuccess)="onEdgeInfoCopied('secret')" [cbContent]="entity?.secret" - [fxShow]="!isEdit && (edgeScope === 'tenant' || edgeScope === 'customer')"> + [fxShow]="!isEdit && edgeScope !== 'customer_user'"> edge.copy-edge-secret @@ -101,7 +101,7 @@ ngxClipboard [disabled]="(isLoading$ | async)" (click)="onEntityAction($event, 'syncEdge')" - [fxShow]="!isEdit && (edgeScope === 'tenant' || edgeScope === 'customer')"> + [fxShow]="!isEdit && edgeScope !== 'customer_user'"> edge.sync @@ -132,7 +132,7 @@ [required]="true" [entityType]="entityType.EDGE"> -
+
edge.edge-license-key @@ -142,7 +142,7 @@
-
+
edge.cloud-endpoint-hint
edge.cloud-endpoint @@ -153,7 +153,7 @@
-
+
edge.edge-key @@ -164,7 +164,7 @@
-
+
edge.edge-secret diff --git a/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts index 96251948d5..6cbeff0efe 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts @@ -477,8 +477,8 @@ export class EdgesTableConfigResolver implements Resolve this.onEntityViewAction(action); - this.config.detailsReadonly = () => this.config.componentsData.entityViewScope === 'customer_user'; + this.config.detailsReadonly = () => (this.config.componentsData.entityViewScope === 'customer_user' || this.config.componentsData.entityViewScope === 'edge_customer_user'); this.config.headerComponent = EntityViewTableHeaderComponent; @@ -152,7 +152,7 @@ export class EntityViewsTableConfigResolver implements Resolve this.config.componentsData.entityViewScope === 'tenant'; return this.config; diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index eaca7133a0..f1a42344fb 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -57,13 +57,13 @@ language.language - + {{ lang ? ('language.locales.' + lang | translate) : ''}} -
+
, private route: ActivatedRoute, @@ -55,6 +57,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir public dialogService: DialogService, public fb: FormBuilder) { super(store); + this.authUser = getCurrentAuthUser(this.store); } ngOnInit() { @@ -134,4 +137,8 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir return this.profile; } + isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } + } diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts index 55dc474a3d..d6759310ac 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts @@ -24,7 +24,6 @@ import { import { Resolve } from '@angular/router'; import { Resource, ResourceInfo, ResourceTypeTranslationMap } from '@shared/models/resource.models'; import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; -import { Direction } from '@shared/models/page/sort-order'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { DatePipe } from '@angular/common'; import { TranslateService } from '@ngx-translate/core'; @@ -34,13 +33,14 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; import { ResourcesLibraryComponent } from '@home/pages/resource/resources-library.component'; -import { Observable } from 'rxjs/internal/Observable'; -import { PageData } from '@shared/models/page/page-data'; +import { PageLink } from '@shared/models/page/page-link'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { map } from 'rxjs/operators'; @Injectable() -export class ResourcesLibraryTableConfigResolver implements Resolve> { +export class ResourcesLibraryTableConfigResolver implements Resolve> { - private readonly config: EntityTableConfig = new EntityTableConfig(); + private readonly config: EntityTableConfig = new EntityTableConfig(); private readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(private store: Store, @@ -52,17 +52,16 @@ export class ResourcesLibraryTableConfigResolver implements Resolve resource ? resource.title : ''; this.config.columns.push( new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), - new EntityTableColumn('title', 'widgets-bundle.title', '60%'), + new EntityTableColumn('title', 'resource.title', '60%'), new EntityTableColumn('resourceType', 'resource.resource-type', '40%', entity => this.resourceTypesTranslationMap.get(entity.resourceType)), - new EntityTableColumn('tenantId', 'widgets-bundle.system', '60px', + new EntityTableColumn('tenantId', 'resource.system', '60px', entity => { return checkBoxCell(entity.tenantId.id === NULL_UUID); }), @@ -83,13 +82,34 @@ export class ResourcesLibraryTableConfigResolver implements Resolve this.translate.instant('resource.delete-resources-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); - this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink) as Observable>; + this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink); this.config.loadEntity = id => this.resourceService.getResource(id.id); - this.config.saveEntity = resource => this.resourceService.saveResource(resource); + this.config.saveEntity = resource => this.saveResource(resource); this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); + + this.config.onEntityAction = action => this.onResourceAction(action); + } + + saveResource(resource) { + if (Array.isArray(resource.data)) { + const resources = []; + resource.data.forEach((data, index) => { + resources.push({ + resourceType: resource.resourceType, + data, + fileName: resource.fileName[index], + title: resource.title + }); + }); + return this.resourceService.saveResources(resources, {resendRequest: true}).pipe( + map((response) => response[0]) + ); + } else { + return this.resourceService.saveResource(resource); + } } - resolve(): EntityTableConfig { + resolve(): EntityTableConfig { this.config.tableTitle = this.translate.instant('resource.resources-library'); const authUser = getCurrentAuthUser(this.store); this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); @@ -105,7 +125,16 @@ export class ResourcesLibraryTableConfigResolver implements Resolve): boolean { + switch (action.action) { + case 'uploadResource': + this.exportResource(action.event, action.entity); + return true; + } + return false; + } + + private isResourceEditable(resource: ResourceInfo, authority: Authority): boolean { if (authority === Authority.TENANT_ADMIN) { return resource && resource.tenantId && resource.tenantId.id !== NULL_UUID; } else { diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html index e4fa600f9c..5b98fa9c61 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html @@ -16,6 +16,12 @@ -->
+ -
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts index 2b9572b7fa..b27fc2e997 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts @@ -211,53 +211,51 @@ export class RuleChainsTableConfigResolver implements Resolve> { const actions: Array> = []; - if (ruleChainScope === 'tenant' || ruleChainScope === 'edges') { + actions.push( + { + name: this.translate.instant('rulechain.open-rulechain'), + icon: 'settings_ethernet', + isEnabled: () => true, + onAction: ($event, entity) => this.openRuleChain($event, entity) + }, + { + name: this.translate.instant('rulechain.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportRuleChain($event, entity) + } + ); + if (ruleChainScope === 'tenant') { actions.push( { - name: this.translate.instant('rulechain.open-rulechain'), - icon: 'settings_ethernet', - isEnabled: () => true, - onAction: ($event, entity) => this.openRuleChain($event, entity) + name: this.translate.instant('rulechain.set-root'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setRootRuleChain($event, entity) + } + ); + } + if (ruleChainScope === 'edges') { + actions.push( + { + name: this.translate.instant('rulechain.set-edge-template-root-rulechain'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setEdgeTemplateRootRuleChain($event, entity) }, { - name: this.translate.instant('rulechain.export'), - icon: 'file_download', - isEnabled: () => true, - onAction: ($event, entity) => this.exportRuleChain($event, entity) + name: this.translate.instant('rulechain.set-auto-assign-to-edge'), + icon: 'bookmark_outline', + isEnabled: (entity) => this.isNotAutoAssignToEdgeRuleChain(entity), + onAction: ($event, entity) => this.setAutoAssignToEdgeRuleChain($event, entity) + }, + { + name: this.translate.instant('rulechain.unset-auto-assign-to-edge'), + icon: 'bookmark', + isEnabled: (entity) => this.isAutoAssignToEdgeRuleChain(entity), + onAction: ($event, entity) => this.unsetAutoAssignToEdgeRuleChain($event, entity) } ); - if (ruleChainScope === 'tenant') { - actions.push( - { - name: this.translate.instant('rulechain.set-root'), - icon: 'flag', - isEnabled: (entity) => this.isNonRootRuleChain(entity), - onAction: ($event, entity) => this.setRootRuleChain($event, entity) - } - ); - } - if (ruleChainScope === 'edges') { - actions.push( - { - name: this.translate.instant('rulechain.set-edge-template-root-rulechain'), - icon: 'flag', - isEnabled: (entity) => this.isNonRootRuleChain(entity), - onAction: ($event, entity) => this.setEdgeTemplateRootRuleChain($event, entity) - }, - { - name: this.translate.instant('rulechain.set-auto-assign-to-edge'), - icon: 'bookmark_outline', - isEnabled: (entity) => this.isNotAutoAssignToEdgeRuleChain(entity), - onAction: ($event, entity) => this.setAutoAssignToEdgeRuleChain($event, entity) - }, - { - name: this.translate.instant('rulechain.unset-auto-assign-to-edge'), - icon: 'bookmark', - isEnabled: (entity) => this.isAutoAssignToEdgeRuleChain(entity), - onAction: ($event, entity) => this.unsetAutoAssignToEdgeRuleChain($event, entity) - } - ); - } } if (ruleChainScope === 'edge') { actions.push( @@ -301,6 +299,8 @@ export class RuleChainsTableConfigResolver implements Resolve + [flowConfig]="{allowDuplicateUploads: true}">
diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index b91fba1def..0fcaf8b790 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -17,6 +17,7 @@ import { AfterViewInit, Component, + ElementRef, EventEmitter, forwardRef, Input, @@ -102,17 +103,34 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, existingFileName: string; @Input() - convertToBase64 = false; + readAsBinary = false; + + private multipleFileValue = false; + + @Input() + set multipleFile(value: boolean) { + this.multipleFileValue = value; + if (this.flow?.flowJs) { + this.updateMultipleFileMode(this.multipleFile); + } + } + + get multipleFile(): boolean { + return this.multipleFileValue; + } @Output() - fileNameChanged = new EventEmitter(); + fileNameChanged = new EventEmitter(); - fileName: string; + fileName: string | string[]; fileContent: any; @ViewChild('flow', {static: true}) flow: FlowDirective; + @ViewChild('flowInput', {static: true}) + flowInput: ElementRef; + autoUploadSubscription: Subscription; private propagateChange = null; @@ -125,34 +143,60 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, ngAfterViewInit() { this.autoUploadSubscription = this.flow.events$.subscribe(event => { - if (event.type === 'fileAdded') { - const file = event.event[0] as flowjs.FlowFile; - if (this.filterFile(file)) { - const reader = new FileReader(); - reader.onload = (loadEvent) => { - if (typeof reader.result === 'string') { - const fileContent = this.convertToBase64 ? window.btoa(reader.result) : reader.result; - if (fileContent && fileContent.length > 0) { - if (this.contentConvertFunction) { - this.fileContent = this.contentConvertFunction(fileContent); - } else { - this.fileContent = fileContent; - } - if (this.fileContent) { - this.fileName = file.name; - } else { - this.fileName = null; - } - this.updateModel(); - } + if (event.type === 'filesAdded') { + const readers = []; + (event.event[0] as flowjs.FlowFile[]).forEach(file => { + if (this.filterFile(file)) { + readers.push(this.readerAsFile(file)); + } + }); + if (readers.length) { + Promise.all(readers).then((filesContent) => { + filesContent = filesContent.filter(content => content.fileContent != null); + if (filesContent.length === 1) { + this.fileContent = filesContent[0].fileContent; + this.fileName = filesContent[0].fileName; + this.updateModel(); + } else if (filesContent.length > 1) { + this.fileContent = filesContent.map(content => content.fileContent); + this.fileName = filesContent.map(content => content.fileName); + this.updateModel(); + } + }); + } + } + }); + if (!this.multipleFile) { + this.updateMultipleFileMode(this.multipleFile); + } + } + + private readerAsFile(file: flowjs.FlowFile): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + let fileName = null; + let fileContent = null; + if (typeof reader.result === 'string') { + fileContent = reader.result; + if (fileContent && fileContent.length > 0) { + if (this.contentConvertFunction) { + fileContent = this.contentConvertFunction(fileContent); + } + if (fileContent) { + fileName = file.name; } - }; - if (this.convertToBase64) { - reader.readAsBinaryString(file.file); - } else { - reader.readAsText(file.file); } } + resolve({fileContent, fileName}); + }; + reader.onerror = () => { + resolve({fileContent: null, fileName: null}); + }; + if (this.readAsBinary) { + reader.readAsBinaryString(file.file); + } else { + reader.readAsText(file.file); } }); } @@ -207,4 +251,11 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.fileContent = null; this.updateModel(); } + + private updateMultipleFileMode(multiple: boolean) { + this.flow.flowJs.opts.singleFile = !multiple; + if (!multiple) { + this.flowInput.nativeElement.removeAttribute('multiple'); + } + } } diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.ts b/ui-ngx/src/app/shared/components/json-object-edit.component.ts index a5d45d16c7..d82b66e7b6 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.ts +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.ts @@ -22,7 +22,7 @@ import { ActionNotificationHide, ActionNotificationShow } from '@core/notificati import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; -import { guid, isUndefined } from '@core/utils'; +import { guid, isUndefined, isDefinedAndNotNull, isLiteralObject } from '@core/utils'; import { ResizeObserver } from '@juggle/resize-observer'; import { getAce } from '@shared/models/ace/ace.models'; @@ -230,8 +230,7 @@ export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Va this.contentValue = ''; this.objectValid = false; try { - - if (this.modelValue) { + if (isDefinedAndNotNull(this.modelValue)) { this.contentValue = JSON.stringify(this.modelValue, isUndefined(this.sort) ? undefined : (key, objectValue) => { return this.sort(key, objectValue); @@ -260,6 +259,9 @@ export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Va if (this.contentValue && this.contentValue.length > 0) { try { data = JSON.parse(this.contentValue); + if (!isLiteralObject(data)) { + throw new TypeError(`Value is not a valid JSON`); + } this.objectValid = true; this.validationError = ''; } catch (ex) { diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.html b/ui-ngx/src/app/shared/components/time/datetime-period.component.html index 96b2e3d526..96bffe8696 100644 --- a/ui-ngx/src/app/shared/components/time/datetime-period.component.html +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.html @@ -15,8 +15,9 @@ limitations under the License. --> -
-
+
+
datetime.date-from @@ -30,7 +31,8 @@
-
+
datetime.date-to diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts index 3eab8c428d..3f9c712556 100644 --- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts @@ -21,7 +21,6 @@ import { QuickTimeInterval, QuickTimeIntervalTranslationMap } from '@shared/mode @Component({ selector: 'tb-quick-time-interval', templateUrl: './quick-time-interval.component.html', - styleUrls: ['./quick-time-interval.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html index 57c596bbc2..f947c51d3e 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -51,7 +51,7 @@ [fxShow]="timewindowForm.get('realtime.realtimeType').value === realtimeTypes.INTERVAL" [required]="timewindow.selectedTab === timewindowTypes.REALTIME && timewindowForm.get('realtime.realtimeType').value === realtimeTypes.INTERVAL" - style="padding-top: 8px; min-width: 364px"> + style="padding-top: 8px">
@@ -75,6 +75,7 @@ timewindow.time-period timewindow.interval + style="padding-top: 8px">
@@ -134,21 +137,23 @@ (ngModelChange)="onHideAggIntervalChanged()">
-
- aggregation.limit - - - - - +
+ +
+ + + + + +
diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss index 6b25d0b1a7..fd8cb0f331 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../scss/constants"; + :host { width: 100%; height: 100%; @@ -40,21 +42,29 @@ } .limit-slider-container { - >:first-child { - margin-right: 16px; - } - >:last-child { + .limit-slider-value { margin-left: 16px; - } - >:first-child, >:last-child { min-width: 25px; - max-width: 42px; + max-width: 80px; } mat-form-field input[type=number] { text-align: center; } } + @media #{$mat-gt-sm} { + .history-time-input { + min-width: 364px; + } + .limit-slider-container { + > label { + margin-right: 16px; + width: min-content; + max-width: 40%; + } + } + } + } :host ::ng-deep { diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 805a046673..154f3c65f9 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -59,3 +59,8 @@ export interface Resource extends ResourceInfo { data: string; fileName: string; } + +export interface Resources extends ResourceInfo { + data: string|string[]; + fileName: string|string[]; +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 02cd3b93d4..15d36d6753 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -141,6 +141,7 @@ import { FileSizePipe } from '@shared/pipe/file-size.pipe'; import { WidgetsBundleSearchComponent } from '@shared/components/widgets-bundle-search.component'; import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; @NgModule({ providers: [ @@ -154,6 +155,10 @@ import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-i { provide: FlowInjectionToken, useValue: Flow + }, + { + provide: MAT_DATE_LOCALE, + useValue: 'en-GB' } ], declarations: [ 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 bbd7f7b61d..c720981fc6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1943,7 +1943,7 @@ "selected-rulechains": "已选择 { count, plural, 1 {# 个规则链} other {# 个规则链} }", "set-root": "设置为根规则链", "set-root-rulechain-text": "确认之后,规则链将变为根规格链,并将处理所有传入的传输消息。", - "set-root-rulechain-title": "您确定要生成规则链'{{RuleChainName}}'根吗?", + "set-root-rulechain-title": "您确定要生成规则链'{{ruleChainName}}'根吗?", "system": "系统" }, "rulenode": {