From f6bc0791f10dc20260ea04dec20429c93ed18840 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Mon, 20 Feb 2017 19:02:03 +0200 Subject: [PATCH 01/14] Aggregation --- .../plugin/PluginProcessingContext.java | 29 ++- .../server/common/data/kv/Aggregation.java | 10 + .../server/common/data/kv/BaseTsKvQuery.java | 62 ++---- .../server/common/data/kv/TsKvQuery.java | 8 +- .../server/dao/model/ModelConstants.java | 74 +++++-- .../AggregatePartitionsFunction.java | 175 ++++++++++++++++ .../dao/timeseries/BaseTimeseriesDao.java | 197 ++++++++++++++---- .../dao/timeseries/BaseTimeseriesService.java | 55 ++--- .../server/dao/timeseries/TimeseriesDao.java | 7 +- .../dao/timeseries/TimeseriesService.java | 4 +- .../thingsboard/server/dao/DaoTestSuite.java | 10 +- .../dao/timeseries/TimeseriesServiceTest.java | 26 ++- .../test/resources/cassandra-test.properties | 6 +- .../extensions/api/plugins/PluginContext.java | 2 + .../handlers/TelemetryRestMsgHandler.java | 5 +- 15 files changed, 494 insertions(+), 176 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java index 92e0ec4101..6490124df9 100644 --- a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - * + *

+ * 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. @@ -17,6 +17,7 @@ package org.thingsboard.server.actors.plugin; import java.io.IOException; import java.util.*; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.stream.Collectors; @@ -152,7 +153,19 @@ public final class PluginProcessingContext implements PluginContext { @Override public List loadTimeseries(DeviceId deviceId, TsKvQuery query) { validate(deviceId); - return pluginCtx.tsService.find(DataConstants.DEVICE, deviceId, query); + try { + return pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, query).get(); + } catch (Exception e) { + log.error("TODO", e); + throw new RuntimeException(e); + } + } + + @Override + public void loadTimeseries(DeviceId deviceId, TsKvQuery query, PluginCallback> callback) { + validate(deviceId); + ListenableFuture> future = pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, query); + Futures.addCallback(future, getCallback(callback, v -> v), executor); } @Override @@ -235,10 +248,10 @@ public final class PluginProcessingContext implements PluginContext { }; } - private FutureCallback getCallback(final PluginCallback callback, Function transformer) { - return new FutureCallback() { + private FutureCallback getCallback(final PluginCallback callback, Function transformer) { + return new FutureCallback() { @Override - public void onSuccess(@Nullable ResultSet result) { + public void onSuccess(@Nullable R result) { pluginCtx.self().tell(PluginCallbackMessage.onSuccess(callback, transformer.apply(result)), ActorRef.noSender()); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java new file mode 100644 index 0000000000..f8fad6c874 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java @@ -0,0 +1,10 @@ +package org.thingsboard.server.common.data.kv; + +/** + * Created by ashvayka on 20.02.17. + */ +public enum Aggregation { + + MIN, MAX, AVG, SUM, COUNT, NONE; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java index 7bb1a3fca5..78c887fe85 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - * + *

+ * 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. @@ -15,59 +15,27 @@ */ package org.thingsboard.server.common.data.kv; -import java.util.Optional; +import lombok.Data; +@Data public class BaseTsKvQuery implements TsKvQuery { - private String key; - private Optional startTs; - private Optional endTs; - private Optional limit; + private final String key; + private final long startTs; + private final long endTs; + private final int limit; + private final Aggregation aggregation; - public BaseTsKvQuery(String key, Optional startTs, Optional endTs, Optional limit) { + public BaseTsKvQuery(String key, long startTs, long endTs, int limit, Aggregation aggregation) { this.key = key; this.startTs = startTs; this.endTs = endTs; this.limit = limit; - } - - public BaseTsKvQuery(String key, Long startTs, Long endTs, Integer limit) { - this(key, Optional.ofNullable(startTs), Optional.ofNullable(endTs), Optional.ofNullable(limit)); - } - - public BaseTsKvQuery(String key, Long startTs, Integer limit) { - this(key, startTs, null, limit); - } - - public BaseTsKvQuery(String key, Long startTs, Long endTs) { - this(key, startTs, endTs, null); - } - - public BaseTsKvQuery(String key, Long startTs) { - this(key, startTs, null, null); + this.aggregation = aggregation; } - public BaseTsKvQuery(String key, Integer limit) { - this(key, null, null, limit); + public BaseTsKvQuery(String key, long startTs, long endTs) { + this(key, startTs, endTs, 1, Aggregation.AVG); } - @Override - public String getKey() { - return key; - } - - @Override - public Optional getStartTs() { - return startTs; - } - - @Override - public Optional getEndTs() { - return endTs; - } - - @Override - public Optional getLimit() { - return limit; - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java index 1303117d63..10a13ce797 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java @@ -21,10 +21,12 @@ public interface TsKvQuery { String getKey(); - Optional getStartTs(); + long getStartTs(); - Optional getEndTs(); + long getEndTs(); - Optional getLimit(); + int getLimit(); + + Aggregation getAggregation(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index b68fb75b5a..0f8418abbe 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - * + *

+ * 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. @@ -18,14 +18,15 @@ package org.thingsboard.server.dao.model; import java.util.UUID; import com.datastax.driver.core.utils.UUIDs; +import org.apache.commons.lang3.ArrayUtils; public class ModelConstants { private ModelConstants() { } - + public static UUID NULL_UUID = UUIDs.startOf(0); - + /** * Generic constants. */ @@ -38,7 +39,7 @@ public class ModelConstants { public static final String ALIAS_PROPERTY = "alias"; public static final String SEARCH_TEXT_PROPERTY = "search_text"; public static final String ADDITIONAL_INFO_PROPERTY = "additional_info"; - + /** * Cassandra user constants. */ @@ -50,11 +51,11 @@ public class ModelConstants { public static final String USER_FIRST_NAME_PROPERTY = "first_name"; public static final String USER_LAST_NAME_PROPERTY = "last_name"; public static final String USER_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - + public static final String USER_BY_EMAIL_COLUMN_FAMILY_NAME = "user_by_email"; public static final String USER_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "user_by_tenant_and_search_text"; public static final String USER_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "user_by_customer_and_search_text"; - + /** * Cassandra user_credentials constants. */ @@ -64,20 +65,20 @@ public class ModelConstants { public static final String USER_CREDENTIALS_PASSWORD_PROPERTY = "password"; public static final String USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY = "activate_token"; public static final String USER_CREDENTIALS_RESET_TOKEN_PROPERTY = "reset_token"; - + public static final String USER_CREDENTIALS_BY_USER_COLUMN_FAMILY_NAME = "user_credentials_by_user"; public static final String USER_CREDENTIALS_BY_ACTIVATE_TOKEN_COLUMN_FAMILY_NAME = "user_credentials_by_activate_token"; public static final String USER_CREDENTIALS_BY_RESET_TOKEN_COLUMN_FAMILY_NAME = "user_credentials_by_reset_token"; - + /** * Cassandra admin_settings constants. */ public static final String ADMIN_SETTINGS_COLUMN_FAMILY_NAME = "admin_settings"; public static final String ADMIN_SETTINGS_KEY_PROPERTY = "key"; public static final String ADMIN_SETTINGS_JSON_VALUE_PROPERTY = "json_value"; - + public static final String ADMIN_SETTINGS_BY_KEY_COLUMN_FAMILY_NAME = "admin_settings_by_key"; - + /** * Cassandra contact constants. */ @@ -97,9 +98,9 @@ public class ModelConstants { public static final String TENANT_TITLE_PROPERTY = TITLE_PROPERTY; public static final String TENANT_REGION_PROPERTY = "region"; public static final String TENANT_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - + public static final String TENANT_BY_REGION_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "tenant_by_region_and_search_text"; - + /** * Cassandra customer constants. */ @@ -107,9 +108,9 @@ public class ModelConstants { public static final String CUSTOMER_TENANT_ID_PROPERTY = TENTANT_ID_PROPERTY; public static final String CUSTOMER_TITLE_PROPERTY = TITLE_PROPERTY; public static final String CUSTOMER_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - + public static final String CUSTOMER_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "customer_by_tenant_and_search_text"; - + /** * Cassandra device constants. */ @@ -118,12 +119,12 @@ public class ModelConstants { public static final String DEVICE_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String DEVICE_NAME_PROPERTY = "name"; public static final String DEVICE_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - + public static final String DEVICE_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_tenant_and_search_text"; public static final String DEVICE_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_customer_and_search_text"; public static final String DEVICE_BY_TENANT_AND_NAME_VIEW_NAME = "device_by_tenant_and_name"; - + /** * Cassandra device_credentials constants. */ @@ -132,7 +133,7 @@ public class ModelConstants { public static final String DEVICE_CREDENTIALS_CREDENTIALS_TYPE_PROPERTY = "credentials_type"; public static final String DEVICE_CREDENTIALS_CREDENTIALS_ID_PROPERTY = "credentials_id"; public static final String DEVICE_CREDENTIALS_CREDENTIALS_VALUE_PROPERTY = "credentials_value"; - + public static final String DEVICE_CREDENTIALS_BY_DEVICE_COLUMN_FAMILY_NAME = "device_credentials_by_device"; public static final String DEVICE_CREDENTIALS_BY_CREDENTIALS_ID_COLUMN_FAMILY_NAME = "device_credentials_by_credentials_id"; @@ -203,9 +204,9 @@ public class ModelConstants { public static final String COMPONENT_DESCRIPTOR_BY_SCOPE_TYPE_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "component_desc_by_scope_type_search_text"; public static final String COMPONENT_DESCRIPTOR_BY_ID = "component_desc_by_id"; - /** - * Cassandra rule metadata constants. - */ + /** + * Cassandra rule metadata constants. + */ public static final String RULE_COLUMN_FAMILY_NAME = "rule"; public static final String RULE_TENANT_ID_PROPERTY = TENTANT_ID_PROPERTY; public static final String RULE_NAME_PROPERTY = "name"; @@ -259,4 +260,31 @@ public class ModelConstants { public static final String STRING_VALUE_COLUMN = "str_v"; public static final String LONG_VALUE_COLUMN = "long_v"; public static final String DOUBLE_VALUE_COLUMN = "dbl_v"; + + public static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN)}; + + public static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN,}; + public static final String[] MIN_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, + new String[]{min(LONG_VALUE_COLUMN), min(DOUBLE_VALUE_COLUMN), min(BOOLEAN_VALUE_COLUMN), min(STRING_VALUE_COLUMN)}); + public static final String[] MAX_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, + new String[]{max(LONG_VALUE_COLUMN), max(DOUBLE_VALUE_COLUMN), max(BOOLEAN_VALUE_COLUMN), max(STRING_VALUE_COLUMN)}); + public static final String[] SUM_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, + new String[]{sum(LONG_VALUE_COLUMN), sum(DOUBLE_VALUE_COLUMN)}); + public static final String[] AVG_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, SUM_AGGREGATION_COLUMNS); + + public static String min(String s) { + return "min(" + s + ")"; + } + + public static String max(String s) { + return "max(" + s + ")"; + } + + public static String sum(String s) { + return "sum(" + s + ")"; + } + + public static String count(String s) { + return "count(" + s + ")"; + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java new file mode 100644 index 0000000000..9ee902213b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java @@ -0,0 +1,175 @@ +package org.thingsboard.server.dao.timeseries; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Row; +import org.thingsboard.server.common.data.kv.*; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Optional; + +/** + * Created by ashvayka on 20.02.17. + */ +public class AggregatePartitionsFunction implements com.google.common.base.Function, Optional> { + + private static final int LONG_CNT_POS = 0; + private static final int DOUBLE_CNT_POS = 1; + private static final int BOOL_CNT_POS = 2; + private static final int STR_CNT_POS = 3; + private static final int LONG_POS = 4; + private static final int DOUBLE_POS = 5; + private static final int BOOL_POS = 6; + private static final int STR_POS = 7; + + private final Aggregation aggregation; + private final String key; + private final long ts; + + public AggregatePartitionsFunction(Aggregation aggregation, String key, long ts) { + this.aggregation = aggregation; + this.key = key; + this.ts = ts; + } + + @Nullable + @Override + public Optional apply(@Nullable List rsList) { + if (rsList == null || rsList.size() == 0) { + return Optional.empty(); + } + long count = 0; + DataType dataType = null; + + Boolean bValue = null; + String sValue = null; + Double dValue = null; + Long lValue = null; + + for (ResultSet rs : rsList) { + for (Row row : rs.all()) { + long curCount; + + Long curLValue = null; + Double curDValue = null; + Boolean curBValue = null; + String curSValue = null; + + long longCount = row.getLong(LONG_CNT_POS); + long doubleCount = row.getLong(DOUBLE_CNT_POS); + long boolCount = row.getLong(BOOL_CNT_POS); + long strCount = row.getLong(STR_CNT_POS); + + if (longCount > 0) { + dataType = DataType.LONG; + curCount = longCount; + curLValue = getLongValue(row); + } else if (doubleCount > 0) { + dataType = DataType.DOUBLE; + curCount = doubleCount; + curDValue = getDoubleValue(row); + } else if (boolCount > 0) { + dataType = DataType.BOOLEAN; + curCount = boolCount; + curBValue = getBooleanValue(row); + } else if (strCount > 0) { + dataType = DataType.STRING; + curCount = strCount; + curSValue = getStringValue(row); + } else { + continue; + } + + if (aggregation == Aggregation.COUNT) { + count += curCount; + } else if (aggregation == Aggregation.AVG || aggregation == Aggregation.SUM) { + count += curCount; + dValue = dValue == null ? curDValue : dValue + curDValue; + lValue = lValue == null ? curLValue : lValue + curLValue; + } else if (aggregation == Aggregation.MIN) { + if (curDValue != null) { + dValue = dValue == null ? curDValue : Math.min(dValue, curDValue); + } else if (curLValue != null) { + lValue = lValue == null ? curLValue : Math.min(lValue, curLValue); + } else if (curBValue != null) { + bValue = bValue == null ? curBValue : bValue && curBValue; + } else if (curSValue != null) { + if (sValue == null || curSValue.compareTo(sValue) < 0) { + sValue = curSValue; + } + } + } else if (aggregation == Aggregation.MAX) { + if (curDValue != null) { + dValue = dValue == null ? curDValue : Math.max(dValue, curDValue); + } else if (curLValue != null) { + lValue = lValue == null ? curLValue : Math.max(lValue, curLValue); + } else if (curBValue != null) { + bValue = bValue == null ? curBValue : bValue || curBValue; + } else if (curSValue != null) { + if (sValue == null || curSValue.compareTo(sValue) > 0) { + sValue = curSValue; + } + } + } + } + } + if (dataType == null) { + return Optional.empty(); + } else if (aggregation == Aggregation.COUNT) { + return Optional.of(new BasicTsKvEntry(ts, new LongDataEntry(key, (long) count))); + } else if (aggregation == Aggregation.AVG || aggregation == Aggregation.SUM) { + if (count == 0 || (dataType == DataType.DOUBLE && dValue == null) || (dataType == DataType.LONG && lValue == null)) { + return Optional.empty(); + } else if (dataType == DataType.DOUBLE) { + return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.SUM ? dValue : (dValue / count)))); + } else if (dataType == DataType.LONG) { + return Optional.of(new BasicTsKvEntry(ts, new LongDataEntry(key, aggregation == Aggregation.SUM ? lValue : (lValue / count)))); + } + } else if (aggregation == Aggregation.MIN || aggregation == Aggregation.MAX) { + if (dataType == DataType.DOUBLE) { + return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, dValue))); + } else if (dataType == DataType.LONG) { + return Optional.of(new BasicTsKvEntry(ts, new LongDataEntry(key, lValue))); + } else if (dataType == DataType.STRING) { + return Optional.of(new BasicTsKvEntry(ts, new StringDataEntry(key, sValue))); + } else { + return Optional.of(new BasicTsKvEntry(ts, new BooleanDataEntry(key, bValue))); + } + } + return null; + } + + private Boolean getBooleanValue(Row row) { + if (aggregation == Aggregation.MIN || aggregation == Aggregation.MAX) { + return row.getBool(BOOL_POS); + } else { + return null; + } + } + + private String getStringValue(Row row) { + if (aggregation == Aggregation.MIN || aggregation == Aggregation.MAX) { + return row.getString(STR_POS); + } else { + return null; + } + } + + private Long getLongValue(Row row) { + if (aggregation == Aggregation.MIN || aggregation == Aggregation.MAX + || aggregation == Aggregation.SUM || aggregation == Aggregation.AVG) { + return row.getLong(LONG_POS); + } else { + return null; + } + } + + private Double getDoubleValue(Row row) { + if (aggregation == Aggregation.MIN || aggregation == Aggregation.MAX + || aggregation == Aggregation.SUM || aggregation == Aggregation.AVG) { + return row.getDouble(DOUBLE_POS); + } else { + return null; + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 09c415c0eb..341972902c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - * + *

+ * 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. @@ -18,6 +18,10 @@ package org.thingsboard.server.dao.timeseries; import com.datastax.driver.core.*; import com.datastax.driver.core.querybuilder.QueryBuilder; import com.datastax.driver.core.querybuilder.Select; +import com.google.common.base.Function; +import com.google.common.util.concurrent.AsyncFunction; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -26,7 +30,16 @@ import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.dao.AbstractDao; import org.thingsboard.server.dao.model.ModelConstants; +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; import static com.datastax.driver.core.querybuilder.QueryBuilder.select; @@ -41,48 +54,136 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { @Value("${cassandra.query.max_limit_per_request}") protected Integer maxLimitPerRequest; + @Value("${cassandra.query.read_result_processing_threads}") + private int readResultsProcessingThreads; + + @Value("${cassandra.query.min_read_step}") + private int minReadStep; + + @Value("${cassandra.query.ts_key_value_partitioning}") + private String partitioning; + + private TsPartitionDate tsFormat; + + private ExecutorService readResultsProcessingExecutor; + private PreparedStatement partitionInsertStmt; private PreparedStatement[] latestInsertStmts; private PreparedStatement[] saveStmts; + private PreparedStatement[] fetchStmts; private PreparedStatement findLatestStmt; private PreparedStatement findAllLatestStmt; + @PostConstruct + public void init() { + getFetchStmt(Aggregation.NONE); + readResultsProcessingExecutor = Executors.newFixedThreadPool(readResultsProcessingThreads); + Optional partition = TsPartitionDate.parse(partitioning); + if (partition.isPresent()) { + tsFormat = partition.get(); + } else { + log.warn("Incorrect configuration of partitioning {}", partitioning); + throw new RuntimeException("Failed to parse partitioning property: " + partitioning + "!"); + } + } + + @PreDestroy + public void stop() { + if (readResultsProcessingExecutor != null) { + readResultsProcessingExecutor.shutdownNow(); + } + } + @Override - public List find(String entityType, UUID entityId, TsKvQuery query, Optional minPartition, Optional maxPartition) { - List rows = Collections.emptyList(); - Long[] parts = fetchPartitions(entityType, entityId, query.getKey(), minPartition, maxPartition); - int partsLength = parts.length; - if (parts != null && partsLength > 0) { - int limit = maxLimitPerRequest; - Optional lim = query.getLimit(); - if (lim.isPresent() && lim.get() < maxLimitPerRequest) { - limit = lim.get(); - } + public long toPartitionTs(long ts) { + LocalDateTime time = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneOffset.UTC); + return tsFormat.truncatedTo(time).toInstant(ZoneOffset.UTC).toEpochMilli(); + } - rows = new ArrayList<>(limit); - int lastIdx = partsLength - 1; - for (int i = 0; i < partsLength; i++) { - int currentLimit; - if (rows.size() >= limit) { - break; - } else { - currentLimit = limit - rows.size(); + + private static String[] getFetchColumnNames(Aggregation aggregation) { + switch (aggregation) { + case NONE: + return ModelConstants.NONE_AGGREGATION_COLUMNS; + case MIN: + return ModelConstants.MIN_AGGREGATION_COLUMNS; + case MAX: + return ModelConstants.MAX_AGGREGATION_COLUMNS; + case SUM: + return ModelConstants.SUM_AGGREGATION_COLUMNS; + case COUNT: + return ModelConstants.COUNT_AGGREGATION_COLUMNS; + case AVG: + return ModelConstants.AVG_AGGREGATION_COLUMNS; + default: + throw new RuntimeException("Aggregation type: " + aggregation + " is not supported!"); + } + } + + @Override + public ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query, long minPartition, long maxPartition) { + if (query.getAggregation() == Aggregation.NONE) { + //TODO: + return null; + } else { + long step = Math.max((query.getEndTs() - query.getStartTs()) / query.getLimit(), minReadStep); + long stepTs = query.getStartTs(); + List>> futures = new ArrayList<>(); + while (stepTs < query.getEndTs()) { + long startTs = stepTs; + long endTs = stepTs + step; + TsKvQuery subQuery = new BaseTsKvQuery(query.getKey(), startTs, endTs, 1, query.getAggregation()); + futures.add(findAndAggregateAsync(entityType, entityId, subQuery, toPartitionTs(startTs), toPartitionTs(endTs))); + stepTs = endTs; + } + ListenableFuture>> future = Futures.allAsList(futures); + return Futures.transform(future, new Function>, List>() { + @Nullable + @Override + public List apply(@Nullable List> input) { + return input.stream().filter(v -> v.isPresent()).map(v -> v.get()).collect(Collectors.toList()); } - Long partition = parts[i]; - Select.Where where = select().from(ModelConstants.TS_KV_CF).where(eq(ModelConstants.ENTITY_TYPE_COLUMN, entityType)) - .and(eq(ModelConstants.ENTITY_ID_COLUMN, entityId)) - .and(eq(ModelConstants.KEY_COLUMN, query.getKey())) - .and(eq(ModelConstants.PARTITION_COLUMN, partition)); - if (i == 0 && query.getStartTs().isPresent()) { - where.and(QueryBuilder.gt(ModelConstants.TS_COLUMN, query.getStartTs().get())); - } else if (i == lastIdx && query.getEndTs().isPresent()) { - where.and(QueryBuilder.lte(ModelConstants.TS_COLUMN, query.getEndTs().get())); + }); + } + } + + private ListenableFuture> findAndAggregateAsync(String entityType, UUID entityId, TsKvQuery query, long minPartition, long maxPartition) { + final Aggregation aggregation = query.getAggregation(); + final long startTs = query.getStartTs(); + final long endTs = query.getEndTs(); + final long ts = startTs + (endTs - startTs) / 2; + + ResultSetFuture partitionsFuture = fetchPartitions(entityType, entityId, query.getKey(), minPartition, maxPartition); + com.google.common.base.Function> toArrayFunction = rows -> rows.all().stream() + .map(row -> row.getLong(ModelConstants.PARTITION_COLUMN)).collect(Collectors.toList()); + + ListenableFuture> partitionsListFuture = Futures.transform(partitionsFuture, toArrayFunction, readResultsProcessingExecutor); + + AsyncFunction, List> fetchChunksFunction = partitions -> { + try { + PreparedStatement proto = getFetchStmt(aggregation); + List futures = new ArrayList<>(partitions.size()); + for (Long partition : partitions) { + BoundStatement stmt = proto.bind(); + stmt.setString(0, entityType); + stmt.setUUID(1, entityId); + stmt.setString(2, query.getKey()); + stmt.setLong(3, partition); + stmt.setLong(4, startTs); + stmt.setLong(5, endTs); + log.debug("Generated query [{}] for entityType {} and entityId {}", stmt, entityType, entityId); + futures.add(executeAsyncRead(stmt)); } - where.limit(currentLimit); - rows.addAll(executeRead(where).all()); + return Futures.allAsList(futures); + } catch (Throwable e) { + log.error("Failed to fetch data", e); + throw e; } - } - return convertResultToTsKvEntryList(rows); + }; + + ListenableFuture> aggregationChunks = Futures.transform(partitionsListFuture, fetchChunksFunction, readResultsProcessingExecutor); + + return Futures.transform(aggregationChunks, new AggregatePartitionsFunction(aggregation, query.getKey(), ts), readResultsProcessingExecutor); } @Override @@ -190,13 +291,12 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { * Select existing partitions from the table * {@link ModelConstants#TS_KV_PARTITIONS_CF} for the given entity */ - private Long[] fetchPartitions(String entityType, UUID entityId, String key, Optional minPartition, Optional maxPartition) { + private ResultSetFuture fetchPartitions(String entityType, UUID entityId, String key, long minPartition, long maxPartition) { Select.Where select = QueryBuilder.select(ModelConstants.PARTITION_COLUMN).from(ModelConstants.TS_KV_PARTITIONS_CF).where(eq(ModelConstants.ENTITY_TYPE_COLUMN, entityType)) .and(eq(ModelConstants.ENTITY_ID_COLUMN, entityId)).and(eq(ModelConstants.KEY_COLUMN, key)); - minPartition.ifPresent(startTs -> select.and(QueryBuilder.gte(ModelConstants.PARTITION_COLUMN, minPartition.get()))); - maxPartition.ifPresent(endTs -> select.and(QueryBuilder.lte(ModelConstants.PARTITION_COLUMN, maxPartition.get()))); - ResultSet resultSet = executeRead(select); - return resultSet.all().stream().map(row -> row.getLong(ModelConstants.PARTITION_COLUMN)).toArray(Long[]::new); + select.and(QueryBuilder.gte(ModelConstants.PARTITION_COLUMN, minPartition)); + select.and(QueryBuilder.lte(ModelConstants.PARTITION_COLUMN, maxPartition)); + return executeAsyncRead(select); } private PreparedStatement getSaveStmt(DataType dataType) { @@ -216,6 +316,23 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { return saveStmts[dataType.ordinal()]; } + private PreparedStatement getFetchStmt(Aggregation aggType) { + if (fetchStmts == null) { + fetchStmts = new PreparedStatement[Aggregation.values().length]; + for (Aggregation type : Aggregation.values()) { + fetchStmts[type.ordinal()] = getSession().prepare("SELECT " + + String.join(", ", getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + " = ? " + + "AND " + ModelConstants.ENTITY_ID_COLUMN + " = ? " + + "AND " + ModelConstants.KEY_COLUMN + " = ? " + + "AND " + ModelConstants.PARTITION_COLUMN + " = ? " + + "AND " + ModelConstants.TS_COLUMN + " > ? " + + "AND " + ModelConstants.TS_COLUMN + " <= ?"); + } + } + return fetchStmts[aggType.ordinal()]; + } + private PreparedStatement getLatestStmt(DataType dataType) { if (latestInsertStmts == null) { latestInsertStmts = new PreparedStatement[DataType.values().length]; diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 419b53447e..a8b4ef5111 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - * + *

+ * 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. @@ -23,21 +23,23 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.kv.BaseTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.dao.exception.IncorrectParameterException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.service.Validator; import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import static org.apache.commons.lang3.StringUtils.isBlank; @@ -50,38 +52,14 @@ public class BaseTimeseriesService implements TimeseriesService { public static final int INSERTS_PER_ENTRY = 3; - @Value("${cassandra.query.ts_key_value_partitioning}") - private String partitioning; - @Autowired private TimeseriesDao timeseriesDao; - private TsPartitionDate tsFormat; - - @PostConstruct - public void init() { - Optional partition = TsPartitionDate.parse(partitioning); - if (partition.isPresent()) { - tsFormat = partition.get(); - } else { - log.warn("Incorrect configuration of partitioning {}", partitioning); - throw new RuntimeException("Failed to parse partitioning property: " + partitioning + "!"); - } - } - @Override - public List find(String entityType, UUIDBased entityId, TsKvQuery query) { + public ListenableFuture> findAll(String entityType, UUIDBased entityId, TsKvQuery query) { validate(entityType, entityId); validate(query); - return timeseriesDao.find(entityType, entityId.getId(), query, toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs())); - } - - private Optional toPartitionTs(Optional ts) { - if (ts.isPresent()) { - return Optional.of(toPartitionTs(ts.get())); - } else { - return Optional.empty(); - } + return timeseriesDao.findAllAsync(entityType, entityId.getId(), query, timeseriesDao.toPartitionTs(query.getStartTs()), timeseriesDao.toPartitionTs(query.getEndTs())); } @Override @@ -106,7 +84,7 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("Key value entry can't be null"); } UUID uid = entityId.getId(); - long partitionTs = toPartitionTs(tsKvEntry.getTs()); + long partitionTs = timeseriesDao.toPartitionTs(tsKvEntry.getTs()); List futures = Lists.newArrayListWithExpectedSize(INSERTS_PER_ENTRY); saveAndRegisterFutures(futures, entityType, tsKvEntry, uid, partitionTs); @@ -122,7 +100,7 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("Key value entry can't be null"); } UUID uid = entityId.getId(); - long partitionTs = toPartitionTs(tsKvEntry.getTs()); + long partitionTs = timeseriesDao.toPartitionTs(tsKvEntry.getTs()); saveAndRegisterFutures(futures, entityType, tsKvEntry, uid, partitionTs); } return Futures.allAsList(futures); @@ -144,14 +122,6 @@ public class BaseTimeseriesService implements TimeseriesService { futures.add(timeseriesDao.save(entityType, uid, partitionTs, tsKvEntry)); } - private long toPartitionTs(long ts) { - LocalDateTime time = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneOffset.UTC); - - LocalDateTime parititonTime = tsFormat.truncatedTo(time); - - return parititonTime.toInstant(ZoneOffset.UTC).toEpochMilli(); - } - private static void validate(String entityType, UUIDBased entityId) { Validator.validateString(entityType, "Incorrect entityType " + entityType); Validator.validateId(entityId, "Incorrect entityId " + entityId); @@ -163,5 +133,6 @@ public class BaseTimeseriesService implements TimeseriesService { } else if (isBlank(query.getKey())) { throw new IncorrectParameterException("Incorrect TsKvQuery. Key can't be empty"); } + //TODO: add validation of all params } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java index 294f57445c..83b78da646 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.timeseries; import com.datastax.driver.core.ResultSetFuture; import com.datastax.driver.core.Row; +import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvQuery; @@ -30,7 +31,11 @@ import java.util.UUID; */ public interface TimeseriesDao { - List find(String entityType, UUID entityId, TsKvQuery query, Optional minPartition, Optional maxPartition); + long toPartitionTs(long ts); + + ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query, long minPartition, long maxPartition); + +// List find(String entityType, UUID entityId, TsKvQuery query, Optional minPartition, Optional maxPartition); ResultSetFuture findLatest(String entityType, UUID entityId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index d8b31af5d2..1bafdea37f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -19,6 +19,7 @@ import com.datastax.driver.core.ResultSet; import com.datastax.driver.core.ResultSetFuture; import com.datastax.driver.core.Row; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvQuery; @@ -32,8 +33,7 @@ import java.util.Set; */ public interface TimeseriesService { - //TODO: Replace this with async operation - List find(String entityType, UUIDBased entityId, TsKvQuery query); + ListenableFuture> findAll(String entityType, UUIDBased entityId, TsKvQuery query); ListenableFuture> findLatest(String entityType, UUIDBased entityId, Collection keys); diff --git a/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java index b1502595cb..ac48536793 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java @@ -25,11 +25,11 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClassnameFilters({ - "org.thingsboard.server.dao.service.*Test", - "org.thingsboard.server.dao.kv.*Test", - "org.thingsboard.server.dao.plugin.*Test", - "org.thingsboard.server.dao.rule.*Test", - "org.thingsboard.server.dao.attributes.*Test", +// "org.thingsboard.server.dao.service.*Test", +// "org.thingsboard.server.dao.kv.*Test", +// "org.thingsboard.server.dao.plugin.*Test", +// "org.thingsboard.server.dao.rule.*Test", +// "org.thingsboard.server.dao.attributes.*Test", "org.thingsboard.server.dao.timeseries.*Test" }) public class DaoTestSuite { diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java index 9e4f492dcc..51fce6fbb0 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java @@ -116,14 +116,36 @@ public class TimeseriesServiceTest extends AbstractServiceTest { entries.add(tsKvEntry); } log.debug("Saved all records {}", localDateTime); - List list = tsService.find(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(STRING_KEY, entries.get(599).getTs(), - LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli())); + List list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(STRING_KEY, entries.get(599).getTs(), + LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli(), PARTITION_MINUTES - 599, Aggregation.MIN)).get(); log.debug("Fetched records {}", localDateTime); List expected = entries.subList(600, PARTITION_MINUTES); assertEquals(expected.size(), list.size()); assertEquals(expected, list); } +// @Test +// public void testFindDeviceTsDataByQuery() throws Exception { +// DeviceId deviceId = new DeviceId(UUIDs.timeBased()); +// LocalDateTime localDateTime = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(PARTITION_MINUTES); +// log.debug("Start event time is {}", localDateTime); +// List entries = new ArrayList<>(PARTITION_MINUTES); +// +// for (int i = 0; i < PARTITION_MINUTES; i++) { +// long time = localDateTime.plusMinutes(i).toInstant(ZoneOffset.UTC).toEpochMilli(); +// BasicTsKvEntry tsKvEntry = new BasicTsKvEntry(time, stringKvEntry); +// tsService.save(DataConstants.DEVICE, deviceId, tsKvEntry).get(); +// entries.add(tsKvEntry); +// } +// log.debug("Saved all records {}", localDateTime); +// List list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(STRING_KEY, entries.get(599).getTs(), +// LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli(), PARTITION_MINUTES - 599, Aggregation.MIN)).get(); +// log.debug("Fetched records {}", localDateTime); +// List expected = entries.subList(600, PARTITION_MINUTES); +// assertEquals(expected.size(), list.size()); +// assertEquals(expected, list); +// } + private void saveEntries(DeviceId deviceId, long ts) throws ExecutionException, InterruptedException { tsService.save(DataConstants.DEVICE, deviceId, toTsEntry(ts, stringKvEntry)).get(); diff --git a/dao/src/test/resources/cassandra-test.properties b/dao/src/test/resources/cassandra-test.properties index 82fcbe1949..0a207e7068 100644 --- a/dao/src/test/resources/cassandra-test.properties +++ b/dao/src/test/resources/cassandra-test.properties @@ -2,7 +2,7 @@ cassandra.cluster_name=Thingsboard Cluster cassandra.keyspace_name=thingsboard -cassandra.url=127.0.0.1:9142 +cassandra.url=127.0.0.1:9042 cassandra.ssl=false @@ -47,3 +47,7 @@ cassandra.query.default_fetch_size=2000 cassandra.query.ts_key_value_partitioning=HOURS cassandra.query.max_limit_per_request=1000 + +cassandra.query.read_result_processing_threads=3 + +cassandra.query.min_read_step=100 \ No newline at end of file diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java index fb6ae07962..b5f27a1e5a 100644 --- a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java @@ -84,6 +84,8 @@ public interface PluginContext { List loadTimeseries(DeviceId deviceId, TsKvQuery query); + void loadTimeseries(DeviceId deviceId, TsKvQuery query, PluginCallback> callback); + void loadLatestTimeseries(DeviceId deviceId, Collection keys, PluginCallback> callback); void loadLatestTimeseries(DeviceId deviceId, PluginCallback> callback); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java index d441e81e60..e93f0b5012 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java @@ -95,8 +95,9 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { Optional limit = request.getIntParamValue("limit"); Map> data = new LinkedHashMap<>(); for (String key : keys.split(",")) { - List entries = ctx.loadTimeseries(deviceId, new BaseTsKvQuery(key, startTs, endTs, limit)); - data.put(key, entries.stream().map(v -> new TsData(v.getTs(), v.getValueAsString())).collect(Collectors.toList())); + //TODO: refactoring +// List entries = ctx.loadTimeseries(deviceId, new BaseTsKvQuery(key, startTs, endTs, limit)); +// data.put(key, entries.stream().map(v -> new TsData(v.getTs(), v.getValueAsString())).collect(Collectors.toList())); } msg.getResponseHolder().setResult(new ResponseEntity<>(data, HttpStatus.OK)); } else if ("attributes".equals(entity)) { From 87d05f7c845d2f64e6fe93607e3a946d9146497f Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 21 Feb 2017 12:26:15 +0200 Subject: [PATCH 02/14] Implementation --- .../plugin/PluginProcessingContext.java | 8 +- .../src/main/resources/thingsboard.yml | 2 +- .../server/common/data/kv/Aggregation.java | 15 ++ .../server/common/data/kv/BaseTsKvQuery.java | 8 +- .../server/dao/model/ModelConstants.java | 33 +++- .../AggregatePartitionsFunction.java | 22 ++- .../dao/timeseries/BaseTimeseriesDao.java | 154 +++++++++++------- .../dao/timeseries/BaseTimeseriesService.java | 13 +- .../timeseries/SimpleListenableFuture.java | 33 ++++ .../server/dao/timeseries/TimeseriesDao.java | 2 +- .../dao/timeseries/TsKvQueryCursor.java | 82 ++++++++++ .../thingsboard/server/dao/DaoTestSuite.java | 10 +- .../dao/timeseries/TimeseriesServiceTest.java | 134 ++++++++++----- .../test/resources/cassandra-test.properties | 4 +- 14 files changed, 390 insertions(+), 130 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TsKvQueryCursor.java diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java index 6490124df9..fbd0cce408 100644 --- a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - *

+ * + * 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. diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index d2cac80101..42b37db793 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -140,7 +140,7 @@ cassandra: # Specify partitioning size for timestamp key-value storage. Example MINUTES, HOURS, DAYS, MONTHS ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}" # Specify max data points per request - max_limit_per_request: "${TS_KV_MAX_LIMIT_PER_REQUEST:86400}" + min_aggregation_step_ms: "${TS_KV_MIN_AGGREGATION_STEP_MS:100}" # Actor system parameters actors: diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java index f8fad6c874..479a49ac91 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.common.data.kv; /** diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java index 78c887fe85..ed48206340 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - *

+ * + * 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. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 0f8418abbe..d3ed5d1dd1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - *

+ * + * 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. @@ -19,6 +19,7 @@ import java.util.UUID; import com.datastax.driver.core.utils.UUIDs; import org.apache.commons.lang3.ArrayUtils; +import org.thingsboard.server.common.data.kv.Aggregation; public class ModelConstants { @@ -261,16 +262,17 @@ public class ModelConstants { public static final String LONG_VALUE_COLUMN = "long_v"; public static final String DOUBLE_VALUE_COLUMN = "dbl_v"; + public static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; + public static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN)}; - public static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN,}; public static final String[] MIN_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, new String[]{min(LONG_VALUE_COLUMN), min(DOUBLE_VALUE_COLUMN), min(BOOLEAN_VALUE_COLUMN), min(STRING_VALUE_COLUMN)}); public static final String[] MAX_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, new String[]{max(LONG_VALUE_COLUMN), max(DOUBLE_VALUE_COLUMN), max(BOOLEAN_VALUE_COLUMN), max(STRING_VALUE_COLUMN)}); public static final String[] SUM_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, new String[]{sum(LONG_VALUE_COLUMN), sum(DOUBLE_VALUE_COLUMN)}); - public static final String[] AVG_AGGREGATION_COLUMNS = ArrayUtils.addAll(COUNT_AGGREGATION_COLUMNS, SUM_AGGREGATION_COLUMNS); + public static final String[] AVG_AGGREGATION_COLUMNS = SUM_AGGREGATION_COLUMNS; public static String min(String s) { return "min(" + s + ")"; @@ -287,4 +289,23 @@ public class ModelConstants { public static String count(String s) { return "count(" + s + ")"; } + + public static String[] getFetchColumnNames(Aggregation aggregation) { + switch (aggregation) { + case NONE: + return NONE_AGGREGATION_COLUMNS; + case MIN: + return MIN_AGGREGATION_COLUMNS; + case MAX: + return MAX_AGGREGATION_COLUMNS; + case SUM: + return SUM_AGGREGATION_COLUMNS; + case COUNT: + return COUNT_AGGREGATION_COLUMNS; + case AVG: + return AVG_AGGREGATION_COLUMNS; + default: + throw new RuntimeException("Aggregation type: " + aggregation + " is not supported!"); + } + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java index 9ee902213b..f099eec004 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.dao.timeseries; import com.datastax.driver.core.ResultSet; @@ -84,8 +99,11 @@ public class AggregatePartitionsFunction implements com.google.common.base.Funct count += curCount; } else if (aggregation == Aggregation.AVG || aggregation == Aggregation.SUM) { count += curCount; - dValue = dValue == null ? curDValue : dValue + curDValue; - lValue = lValue == null ? curLValue : lValue + curLValue; + if (curDValue != null) { + dValue = dValue == null ? curDValue : dValue + curDValue; + } else if (curLValue != null) { + lValue = lValue == null ? curLValue : lValue + curLValue; + } } else if (aggregation == Aggregation.MIN) { if (curDValue != null) { dValue = dValue == null ? curDValue : Math.min(dValue, curDValue); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 341972902c..5cf71fcce8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - *

+ * + * 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. @@ -20,6 +20,7 @@ import com.datastax.driver.core.querybuilder.QueryBuilder; import com.datastax.driver.core.querybuilder.Select; import com.google.common.base.Function; import com.google.common.util.concurrent.AsyncFunction; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; @@ -51,14 +52,8 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.select; @Slf4j public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { - @Value("${cassandra.query.max_limit_per_request}") - protected Integer maxLimitPerRequest; - - @Value("${cassandra.query.read_result_processing_threads}") - private int readResultsProcessingThreads; - - @Value("${cassandra.query.min_read_step}") - private int minReadStep; + @Value("${cassandra.query.min_aggregation_step_ms}") + private int minAggregationStepMs; @Value("${cassandra.query.ts_key_value_partitioning}") private String partitioning; @@ -77,7 +72,7 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { @PostConstruct public void init() { getFetchStmt(Aggregation.NONE); - readResultsProcessingExecutor = Executors.newFixedThreadPool(readResultsProcessingThreads); + readResultsProcessingExecutor = Executors.newCachedThreadPool(); Optional partition = TsPartitionDate.parse(partitioning); if (partition.isPresent()) { tsFormat = partition.get(); @@ -100,33 +95,12 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { return tsFormat.truncatedTo(time).toInstant(ZoneOffset.UTC).toEpochMilli(); } - - private static String[] getFetchColumnNames(Aggregation aggregation) { - switch (aggregation) { - case NONE: - return ModelConstants.NONE_AGGREGATION_COLUMNS; - case MIN: - return ModelConstants.MIN_AGGREGATION_COLUMNS; - case MAX: - return ModelConstants.MAX_AGGREGATION_COLUMNS; - case SUM: - return ModelConstants.SUM_AGGREGATION_COLUMNS; - case COUNT: - return ModelConstants.COUNT_AGGREGATION_COLUMNS; - case AVG: - return ModelConstants.AVG_AGGREGATION_COLUMNS; - default: - throw new RuntimeException("Aggregation type: " + aggregation + " is not supported!"); - } - } - @Override - public ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query, long minPartition, long maxPartition) { + public ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query) { if (query.getAggregation() == Aggregation.NONE) { - //TODO: - return null; + return findAllAsyncWithLimit(entityType, entityId, query); } else { - long step = Math.max((query.getEndTs() - query.getStartTs()) / query.getLimit(), minReadStep); + long step = Math.max((query.getEndTs() - query.getStartTs()) / query.getLimit(), minAggregationStepMs); long stepTs = query.getStartTs(); List>> futures = new ArrayList<>(); while (stepTs < query.getEndTs()) { @@ -143,23 +117,88 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { public List apply(@Nullable List> input) { return input.stream().filter(v -> v.isPresent()).map(v -> v.get()).collect(Collectors.toList()); } - }); + }, readResultsProcessingExecutor); + } + } + + private ListenableFuture> findAllAsyncWithLimit(String entityType, UUID entityId, TsKvQuery query) { + long minPartition = query.getStartTs(); + long maxPartition = query.getEndTs(); + + ResultSetFuture partitionsFuture = fetchPartitions(entityType, entityId, query.getKey(), minPartition, maxPartition); + + final SimpleListenableFuture> resultFuture = new SimpleListenableFuture<>(); + final ListenableFuture> partitionsListFuture = Futures.transform(partitionsFuture, getPartitionsArrayFunction(), readResultsProcessingExecutor); + + Futures.addCallback(partitionsListFuture, new FutureCallback>() { + @Override + public void onSuccess(@Nullable List partitions) { + TsKvQueryCursor cursor = new TsKvQueryCursor(entityType, entityId, query, partitions); + findAllAsyncSequentiallyWithLimit(cursor, resultFuture); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityType, entityId, minPartition, maxPartition, t); + } + }, readResultsProcessingExecutor); + + return resultFuture; + } + + private void findAllAsyncSequentiallyWithLimit(final TsKvQueryCursor cursor, final SimpleListenableFuture> resultFuture) { + if (cursor.isFull() || !cursor.hasNextPartition()) { + resultFuture.set(cursor.getData()); + } else { + PreparedStatement proto = getFetchStmt(Aggregation.NONE); + BoundStatement stmt = proto.bind(); + stmt.setString(0, cursor.getEntityType()); + stmt.setUUID(1, cursor.getEntityId()); + stmt.setString(2, cursor.getKey()); + stmt.setLong(3, cursor.getNextPartition()); + stmt.setLong(4, cursor.getStartTs()); + stmt.setLong(5, cursor.getEndTs()); + stmt.setInt(6, cursor.getCurrentLimit()); + + Futures.addCallback(executeAsyncRead(stmt), new FutureCallback() { + @Override + public void onSuccess(@Nullable ResultSet result) { + cursor.addData(convertResultToTsKvEntryList(result.all())); + findAllAsyncSequentiallyWithLimit(cursor, resultFuture); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t); + } + }, readResultsProcessingExecutor); } } private ListenableFuture> findAndAggregateAsync(String entityType, UUID entityId, TsKvQuery query, long minPartition, long maxPartition) { final Aggregation aggregation = query.getAggregation(); + final String key = query.getKey(); final long startTs = query.getStartTs(); final long endTs = query.getEndTs(); final long ts = startTs + (endTs - startTs) / 2; - ResultSetFuture partitionsFuture = fetchPartitions(entityType, entityId, query.getKey(), minPartition, maxPartition); - com.google.common.base.Function> toArrayFunction = rows -> rows.all().stream() - .map(row -> row.getLong(ModelConstants.PARTITION_COLUMN)).collect(Collectors.toList()); + ResultSetFuture partitionsFuture = fetchPartitions(entityType, entityId, key, minPartition, maxPartition); + + ListenableFuture> partitionsListFuture = Futures.transform(partitionsFuture, getPartitionsArrayFunction(), readResultsProcessingExecutor); - ListenableFuture> partitionsListFuture = Futures.transform(partitionsFuture, toArrayFunction, readResultsProcessingExecutor); + ListenableFuture> aggregationChunks = Futures.transform(partitionsListFuture, + getFetchChunksAsyncFunction(entityType, entityId, key, aggregation, startTs, endTs), readResultsProcessingExecutor); - AsyncFunction, List> fetchChunksFunction = partitions -> { + return Futures.transform(aggregationChunks, new AggregatePartitionsFunction(aggregation, key, ts), readResultsProcessingExecutor); + } + + private Function> getPartitionsArrayFunction() { + return rows -> rows.all().stream() + .map(row -> row.getLong(ModelConstants.PARTITION_COLUMN)).collect(Collectors.toList()); + } + + private AsyncFunction, List> getFetchChunksAsyncFunction(String entityType, UUID entityId, String key, Aggregation aggregation, long startTs, long endTs) { + return partitions -> { try { PreparedStatement proto = getFetchStmt(aggregation); List futures = new ArrayList<>(partitions.size()); @@ -167,7 +206,7 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { BoundStatement stmt = proto.bind(); stmt.setString(0, entityType); stmt.setUUID(1, entityId); - stmt.setString(2, query.getKey()); + stmt.setString(2, key); stmt.setLong(3, partition); stmt.setLong(4, startTs); stmt.setLong(5, endTs); @@ -180,10 +219,6 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { throw e; } }; - - ListenableFuture> aggregationChunks = Futures.transform(partitionsListFuture, fetchChunksFunction, readResultsProcessingExecutor); - - return Futures.transform(aggregationChunks, new AggregatePartitionsFunction(aggregation, query.getKey(), ts), readResultsProcessingExecutor); } @Override @@ -320,14 +355,21 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { if (fetchStmts == null) { fetchStmts = new PreparedStatement[Aggregation.values().length]; for (Aggregation type : Aggregation.values()) { - fetchStmts[type.ordinal()] = getSession().prepare("SELECT " + - String.join(", ", getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF - + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + " = ? " - + "AND " + ModelConstants.ENTITY_ID_COLUMN + " = ? " - + "AND " + ModelConstants.KEY_COLUMN + " = ? " - + "AND " + ModelConstants.PARTITION_COLUMN + " = ? " - + "AND " + ModelConstants.TS_COLUMN + " > ? " - + "AND " + ModelConstants.TS_COLUMN + " <= ?"); + if (type == Aggregation.SUM && fetchStmts[Aggregation.AVG.ordinal()] != null) { + fetchStmts[type.ordinal()] = fetchStmts[Aggregation.AVG.ordinal()]; + } else if (type == Aggregation.AVG && fetchStmts[Aggregation.SUM.ordinal()] != null) { + fetchStmts[type.ordinal()] = fetchStmts[Aggregation.SUM.ordinal()]; + } else { + fetchStmts[type.ordinal()] = getSession().prepare("SELECT " + + String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + " = ? " + + "AND " + ModelConstants.ENTITY_ID_COLUMN + " = ? " + + "AND " + ModelConstants.KEY_COLUMN + " = ? " + + "AND " + ModelConstants.PARTITION_COLUMN + " = ? " + + "AND " + ModelConstants.TS_COLUMN + " > ? " + + "AND " + ModelConstants.TS_COLUMN + " <= ?" + + (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " DESC LIMIT ?" : "")); + } } } return fetchStmts[aggType.ordinal()]; diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index a8b4ef5111..1d8c3dfd4e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - *

+ * + * 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. @@ -59,7 +59,7 @@ public class BaseTimeseriesService implements TimeseriesService { public ListenableFuture> findAll(String entityType, UUIDBased entityId, TsKvQuery query) { validate(entityType, entityId); validate(query); - return timeseriesDao.findAllAsync(entityType, entityId.getId(), query, timeseriesDao.toPartitionTs(query.getStartTs()), timeseriesDao.toPartitionTs(query.getEndTs())); + return timeseriesDao.findAllAsync(entityType, entityId.getId(), query); } @Override @@ -132,7 +132,8 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("TsKvQuery can't be null"); } else if (isBlank(query.getKey())) { throw new IncorrectParameterException("Incorrect TsKvQuery. Key can't be empty"); + } else if (query.getAggregation() == null){ + throw new IncorrectParameterException("Incorrect TsKvQuery. Aggregation can't be empty"); } - //TODO: add validation of all params } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java new file mode 100644 index 0000000000..e10a40de5f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.timeseries; + +import com.google.common.util.concurrent.AbstractFuture; + +/** + * Created by ashvayka on 21.02.17. + */ +public class SimpleListenableFuture extends AbstractFuture { + + public SimpleListenableFuture() { + + } + + public boolean set(V value) { + return super.set(value); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java index 83b78da646..7a6eed7eb0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java @@ -33,7 +33,7 @@ public interface TimeseriesDao { long toPartitionTs(long ts); - ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query, long minPartition, long maxPartition); + ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query); // List find(String entityType, UUID entityId, TsKvQuery query, Optional minPartition, Optional maxPartition); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsKvQueryCursor.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsKvQueryCursor.java new file mode 100644 index 0000000000..cad1232d68 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TsKvQueryCursor.java @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.timeseries; + +import lombok.Data; +import lombok.Getter; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.kv.TsKvQuery; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Created by ashvayka on 21.02.17. + */ +public class TsKvQueryCursor { + @Getter + private final String entityType; + @Getter + private final UUID entityId; + @Getter + private final String key; + @Getter + private final long startTs; + @Getter + private final long endTs; + private final List partitions; + @Getter + private final List data; + + private int partitionIndex; + private int currentLimit; + + public TsKvQueryCursor(String entityType, UUID entityId, TsKvQuery baseQuery, List partitions) { + this.entityType = entityType; + this.entityId = entityId; + this.key = baseQuery.getKey(); + this.startTs = baseQuery.getStartTs(); + this.endTs = baseQuery.getEndTs(); + this.partitions = partitions; + this.partitionIndex = partitions.size() - 1; + this.data = new ArrayList<>(); + this.currentLimit = baseQuery.getLimit(); + } + + public boolean hasNextPartition() { + return partitionIndex >= 0; + } + + public boolean isFull() { + return currentLimit <= 0; + } + + public long getNextPartition() { + long partition = partitions.get(partitionIndex); + partitionIndex--; + return partition; + } + + public int getCurrentLimit() { + return currentLimit; + } + + public void addData(List newData) { + currentLimit -= newData.size(); + data.addAll(newData); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java index ac48536793..b1502595cb 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/DaoTestSuite.java @@ -25,11 +25,11 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClassnameFilters({ -// "org.thingsboard.server.dao.service.*Test", -// "org.thingsboard.server.dao.kv.*Test", -// "org.thingsboard.server.dao.plugin.*Test", -// "org.thingsboard.server.dao.rule.*Test", -// "org.thingsboard.server.dao.attributes.*Test", + "org.thingsboard.server.dao.service.*Test", + "org.thingsboard.server.dao.kv.*Test", + "org.thingsboard.server.dao.plugin.*Test", + "org.thingsboard.server.dao.rule.*Test", + "org.thingsboard.server.dao.attributes.*Test", "org.thingsboard.server.dao.timeseries.*Test" }) public class DaoTestSuite { diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java index 51fce6fbb0..13c25c3213 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java @@ -51,8 +51,6 @@ public class TimeseriesServiceTest extends AbstractServiceTest { private static final String DOUBLE_KEY = "doubleKey"; private static final String BOOLEAN_KEY = "booleanKey"; - public static final int PARTITION_MINUTES = 1100; - private static final long TS = 42L; KvEntry stringKvEntry = new StringDataEntry(STRING_KEY, "value"); @@ -103,49 +101,101 @@ public class TimeseriesServiceTest extends AbstractServiceTest { } @Test - public void testFindDeviceTsDataByQuery() throws Exception { + public void testFindDeviceTsData() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); - LocalDateTime localDateTime = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(PARTITION_MINUTES); - log.debug("Start event time is {}", localDateTime); - List entries = new ArrayList<>(PARTITION_MINUTES); - - for (int i = 0; i < PARTITION_MINUTES; i++) { - long time = localDateTime.plusMinutes(i).toInstant(ZoneOffset.UTC).toEpochMilli(); - BasicTsKvEntry tsKvEntry = new BasicTsKvEntry(time, stringKvEntry); - tsService.save(DataConstants.DEVICE, deviceId, tsKvEntry).get(); - entries.add(tsKvEntry); - } - log.debug("Saved all records {}", localDateTime); - List list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(STRING_KEY, entries.get(599).getTs(), - LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli(), PARTITION_MINUTES - 599, Aggregation.MIN)).get(); - log.debug("Fetched records {}", localDateTime); - List expected = entries.subList(600, PARTITION_MINUTES); - assertEquals(expected.size(), list.size()); - assertEquals(expected, list); - } + List entries = new ArrayList<>(); + + entries.add(save(deviceId, 5000, 100)); + entries.add(save(deviceId, 15000, 200)); + + entries.add(save(deviceId, 25000, 300)); + entries.add(save(deviceId, 35000, 400)); + + entries.add(save(deviceId, 45000, 500)); + entries.add(save(deviceId, 55000, 600)); + + List list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.NONE)).get(); + assertEquals(3, list.size()); + assertEquals(55000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(600L), list.get(0).getLongValue()); + + assertEquals(45000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(500L), list.get(1).getLongValue()); + + assertEquals(35000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(400L), list.get(2).getLongValue()); + + list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.AVG)).get(); + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(150L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(350L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(550L), list.get(2).getLongValue()); + + list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.SUM)).get(); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(300L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(700L), list.get(1).getLongValue()); -// @Test -// public void testFindDeviceTsDataByQuery() throws Exception { -// DeviceId deviceId = new DeviceId(UUIDs.timeBased()); -// LocalDateTime localDateTime = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(PARTITION_MINUTES); -// log.debug("Start event time is {}", localDateTime); -// List entries = new ArrayList<>(PARTITION_MINUTES); -// -// for (int i = 0; i < PARTITION_MINUTES; i++) { -// long time = localDateTime.plusMinutes(i).toInstant(ZoneOffset.UTC).toEpochMilli(); -// BasicTsKvEntry tsKvEntry = new BasicTsKvEntry(time, stringKvEntry); -// tsService.save(DataConstants.DEVICE, deviceId, tsKvEntry).get(); -// entries.add(tsKvEntry); -// } -// log.debug("Saved all records {}", localDateTime); -// List list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(STRING_KEY, entries.get(599).getTs(), -// LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli(), PARTITION_MINUTES - 599, Aggregation.MIN)).get(); -// log.debug("Fetched records {}", localDateTime); -// List expected = entries.subList(600, PARTITION_MINUTES); -// assertEquals(expected.size(), list.size()); -// assertEquals(expected, list); -// } + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(1100L), list.get(2).getLongValue()); + list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.MIN)).get(); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(100L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(300L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(500L), list.get(2).getLongValue()); + + list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.MAX)).get(); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(200L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(400L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(600L), list.get(2).getLongValue()); + + list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.COUNT)).get(); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); + } + + private TsKvEntry save(DeviceId deviceId, long ts, long value) throws Exception { + TsKvEntry entry = new BasicTsKvEntry(ts, new LongDataEntry(LONG_KEY, value)); + tsService.save(DataConstants.DEVICE, deviceId, entry).get(); + return entry; + } private void saveEntries(DeviceId deviceId, long ts) throws ExecutionException, InterruptedException { tsService.save(DataConstants.DEVICE, deviceId, toTsEntry(ts, stringKvEntry)).get(); diff --git a/dao/src/test/resources/cassandra-test.properties b/dao/src/test/resources/cassandra-test.properties index 0a207e7068..210d2c0c9e 100644 --- a/dao/src/test/resources/cassandra-test.properties +++ b/dao/src/test/resources/cassandra-test.properties @@ -48,6 +48,4 @@ cassandra.query.ts_key_value_partitioning=HOURS cassandra.query.max_limit_per_request=1000 -cassandra.query.read_result_processing_threads=3 - -cassandra.query.min_read_step=100 \ No newline at end of file +cassandra.query.min_aggregation_step_ms=100 \ No newline at end of file From 9328bbc0b7819341ec121e321c624fbd9ce51a64 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 21 Feb 2017 13:34:57 +0200 Subject: [PATCH 03/14] Aggregation Implementation --- .../plugin/PluginProcessingContext.java | 15 +--- .../dao/timeseries/BaseTimeseriesDao.java | 16 +++- .../dao/timeseries/BaseTimeseriesService.java | 11 ++- .../server/dao/timeseries/TimeseriesDao.java | 4 +- .../dao/timeseries/TimeseriesService.java | 2 +- .../extensions/api/plugins/PluginContext.java | 4 +- .../plugin/telemetry/cmd/GetHistoryCmd.java | 50 +++---------- .../plugin/telemetry/cmd/SubscriptionCmd.java | 42 +---------- .../cmd/TimeseriesSubscriptionCmd.java | 14 ++-- .../TelemetryWebsocketMsgHandler.java | 73 ++++++++++--------- 10 files changed, 82 insertions(+), 149 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java index fbd0cce408..9474a621e1 100644 --- a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java @@ -151,20 +151,9 @@ public final class PluginProcessingContext implements PluginContext { } @Override - public List loadTimeseries(DeviceId deviceId, TsKvQuery query) { + public void loadTimeseries(DeviceId deviceId, List queries, PluginCallback> callback) { validate(deviceId); - try { - return pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, query).get(); - } catch (Exception e) { - log.error("TODO", e); - throw new RuntimeException(e); - } - } - - @Override - public void loadTimeseries(DeviceId deviceId, TsKvQuery query, PluginCallback> callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, query); + ListenableFuture> future = pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, queries); Futures.addCallback(future, getCallback(callback, v -> v), executor); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 5cf71fcce8..81bbdf9309 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -96,7 +96,21 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { } @Override - public ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query) { + public ListenableFuture> findAllAsync(String entityType, UUID entityId, List queries) { + List>> futures = queries.stream().map(query -> findAllAsync(entityType, entityId, query)).collect(Collectors.toList()); + return Futures.transform(Futures.allAsList(futures), new Function>, List>() { + @Nullable + @Override + public List apply(@Nullable List> results) { + List result = new ArrayList(); + results.forEach(r -> result.addAll(r)); + return result; + } + }, readResultsProcessingExecutor); + } + + + private ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query) { if (query.getAggregation() == Aggregation.NONE) { return findAllAsyncWithLimit(entityType, entityId, query); } else { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 1d8c3dfd4e..f27ed6e605 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.timeseries; import com.datastax.driver.core.ResultSet; import com.datastax.driver.core.ResultSetFuture; import com.datastax.driver.core.Row; +import com.google.common.base.Function; import com.google.common.collect.Lists; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -32,6 +33,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.service.Validator; +import javax.annotation.Nullable; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.Instant; @@ -40,6 +42,7 @@ import java.time.ZoneOffset; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.isBlank; @@ -56,10 +59,10 @@ public class BaseTimeseriesService implements TimeseriesService { private TimeseriesDao timeseriesDao; @Override - public ListenableFuture> findAll(String entityType, UUIDBased entityId, TsKvQuery query) { + public ListenableFuture> findAll(String entityType, UUIDBased entityId, List queries) { validate(entityType, entityId); - validate(query); - return timeseriesDao.findAllAsync(entityType, entityId.getId(), query); + queries.forEach(query -> validate(query)); + return timeseriesDao.findAllAsync(entityType, entityId.getId(), queries); } @Override @@ -132,7 +135,7 @@ public class BaseTimeseriesService implements TimeseriesService { throw new IncorrectParameterException("TsKvQuery can't be null"); } else if (isBlank(query.getKey())) { throw new IncorrectParameterException("Incorrect TsKvQuery. Key can't be empty"); - } else if (query.getAggregation() == null){ + } else if (query.getAggregation() == null) { throw new IncorrectParameterException("Incorrect TsKvQuery. Aggregation can't be empty"); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java index 7a6eed7eb0..177003ddf2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java @@ -33,9 +33,7 @@ public interface TimeseriesDao { long toPartitionTs(long ts); - ListenableFuture> findAllAsync(String entityType, UUID entityId, TsKvQuery query); - -// List find(String entityType, UUID entityId, TsKvQuery query, Optional minPartition, Optional maxPartition); + ListenableFuture> findAllAsync(String entityType, UUID entityId, List queries); ResultSetFuture findLatest(String entityType, UUID entityId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index 1bafdea37f..cd53e9468f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -33,7 +33,7 @@ import java.util.Set; */ public interface TimeseriesService { - ListenableFuture> findAll(String entityType, UUIDBased entityId, TsKvQuery query); + ListenableFuture> findAll(String entityType, UUIDBased entityId, List queries); ListenableFuture> findLatest(String entityType, UUIDBased entityId, Collection keys); diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java index b5f27a1e5a..c2c5587704 100644 --- a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java @@ -82,9 +82,7 @@ public interface PluginContext { void saveTsData(DeviceId deviceId, List entry, PluginCallback callback); - List loadTimeseries(DeviceId deviceId, TsKvQuery query); - - void loadTimeseries(DeviceId deviceId, TsKvQuery query, PluginCallback> callback); + void loadTimeseries(DeviceId deviceId, List queries, PluginCallback> callback); void loadLatestTimeseries(DeviceId deviceId, Collection keys, PluginCallback> callback); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java index 14d7aa1f86..9f068950ee 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java @@ -15,9 +15,16 @@ */ package org.thingsboard.server.extensions.core.plugin.telemetry.cmd; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + /** * @author Andrew Shvayka */ +@NoArgsConstructor +@AllArgsConstructor +@Data public class GetHistoryCmd implements TelemetryPluginCmd { private int cmdId; @@ -25,46 +32,7 @@ public class GetHistoryCmd implements TelemetryPluginCmd { private String keys; private long startTs; private long endTs; + private int limit; + private String agg; - @Override - public int getCmdId() { - return cmdId; - } - - @Override - public void setCmdId(int cmdId) { - this.cmdId = cmdId; - } - - public String getDeviceId() { - return deviceId; - } - - public void setDeviceId(String deviceId) { - this.deviceId = deviceId; - } - - public String getKeys() { - return keys; - } - - public void setKeys(String keys) { - this.keys = keys; - } - - public long getStartTs() { - return startTs; - } - - public void setStartTs(long startTs) { - this.startTs = startTs; - } - - public long getEndTs() { - return endTs; - } - - public void setEndTs(long endTs) { - this.endTs = endTs; - } } diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/SubscriptionCmd.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/SubscriptionCmd.java index 718f23a20d..3574eae0ec 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/SubscriptionCmd.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/SubscriptionCmd.java @@ -16,11 +16,13 @@ package org.thingsboard.server.extensions.core.plugin.telemetry.cmd; import lombok.AllArgsConstructor; +import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.extensions.core.plugin.telemetry.sub.SubscriptionType; @NoArgsConstructor @AllArgsConstructor +@Data public abstract class SubscriptionCmd implements TelemetryPluginCmd { private int cmdId; @@ -31,46 +33,6 @@ public abstract class SubscriptionCmd implements TelemetryPluginCmd { public abstract SubscriptionType getType(); - public int getCmdId() { - return cmdId; - } - - public void setCmdId(int cmdId) { - this.cmdId = cmdId; - } - - public String getDeviceId() { - return deviceId; - } - - public void setDeviceId(String deviceId) { - this.deviceId = deviceId; - } - - public String getKeys() { - return keys; - } - - public void setTags(String tags) { - this.keys = tags; - } - - public boolean isUnsubscribe() { - return unsubscribe; - } - - public void setUnsubscribe(boolean unsubscribe) { - this.unsubscribe = unsubscribe; - } - - public String getScope() { - return scope; - } - - public void setKeys(String keys) { - this.keys = keys; - } - @Override public String toString() { return "SubscriptionCmd [deviceId=" + deviceId + ", tags=" + keys + ", unsubscribe=" + unsubscribe + "]"; diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java index 4f24a0041e..f4eacf587a 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.extensions.core.plugin.telemetry.cmd; +import lombok.AllArgsConstructor; +import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.extensions.core.plugin.telemetry.sub.SubscriptionType; @@ -22,17 +24,13 @@ import org.thingsboard.server.extensions.core.plugin.telemetry.sub.SubscriptionT * @author Andrew Shvayka */ @NoArgsConstructor +@AllArgsConstructor +@Data public class TimeseriesSubscriptionCmd extends SubscriptionCmd { private long timeWindow; - - public long getTimeWindow() { - return timeWindow; - } - - public void setTimeWindow(long timeWindow) { - this.timeWindow = timeWindow; - } + private int limit; + private String agg; @Override public SubscriptionType getType() { diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index 5d6e68ee30..739bedfd89 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -158,40 +158,14 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { log.debug("[{}] fetching timeseries data for last {} ms for keys: ({}) for device : {}", sessionId, cmd.getTimeWindow(), cmd.getKeys(), cmd.getDeviceId()); long endTs = System.currentTimeMillis(); startTs = endTs - cmd.getTimeWindow(); - for (String key : keys) { - TsKvQuery query = new BaseTsKvQuery(key, startTs, endTs); - data.addAll(ctx.loadTimeseries(deviceId, query)); - } - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); - Map subState = new HashMap<>(keys.size()); - keys.forEach(key -> subState.put(key, startTs)); - data.forEach(v -> subState.put(v.getKey(), v.getTs())); - SubscriptionState sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.TIMESERIES, false, subState); - subscriptionManager.addLocalWsSubscription(ctx, sessionId, deviceId, sub); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs, endTs, cmd.getLimit(), Aggregation.valueOf(cmd.getAgg()))).collect(Collectors.toList()); + ctx.loadTimeseries(deviceId, queries, getSubscriptionCallback(sessionRef, cmd, sessionId, deviceId, startTs, keys)); } else { List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); startTs = System.currentTimeMillis(); log.debug("[{}] fetching latest timeseries data for keys: ({}) for device : {}", sessionId, cmd.getKeys(), cmd.getDeviceId()); - ctx.loadLatestTimeseries(deviceId, keys, new PluginCallback>() { - @Override - public void onSuccess(PluginContext ctx, List data) { - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); - - Map subState = new HashMap<>(keys.size()); - keys.forEach(key -> subState.put(key, startTs)); - data.forEach(v -> subState.put(v.getKey(), v.getTs())); - SubscriptionState sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.TIMESERIES, false, subState); - subscriptionManager.addLocalWsSubscription(ctx, sessionId, deviceId, sub); - } - - @Override - public void onFailure(PluginContext ctx, Exception e) { - SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, - "Failed to fetch data!"); - sendWsMsg(ctx, sessionRef, update); - } - }); + ctx.loadLatestTimeseries(deviceId, keys, getSubscriptionCallback(sessionRef, cmd, sessionId, deviceId, startTs, keys)); } } else { ctx.loadLatestTimeseries(deviceId, new PluginCallback>() { @@ -216,6 +190,28 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { } } + private PluginCallback> getSubscriptionCallback(final PluginWebsocketSessionRef sessionRef, final TimeseriesSubscriptionCmd cmd, final String sessionId, final DeviceId deviceId, final long startTs, final List keys) { + return new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List data) { + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); + + Map subState = new HashMap<>(keys.size()); + keys.forEach(key -> subState.put(key, startTs)); + data.forEach(v -> subState.put(v.getKey(), v.getTs())); + SubscriptionState sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.TIMESERIES, false, subState); + subscriptionManager.addLocalWsSubscription(ctx, sessionId, deviceId, sub); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch data!"); + sendWsMsg(ctx, sessionRef, update); + } + }; + } + private void handleWsHistoryCmd(PluginContext ctx, PluginWebsocketSessionRef sessionRef, GetHistoryCmd cmd) { String sessionId = sessionRef.getSessionId(); WsSessionMetaData sessionMD = wsSessionsMap.get(sessionId); @@ -246,12 +242,19 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { return; } List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); - List data = new ArrayList<>(); - for (String key : keys) { - TsKvQuery query = new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs()); - data.addAll(ctx.loadTimeseries(deviceId, query)); - } - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), cmd.getLimit(), Aggregation.valueOf(cmd.getAgg()))).collect(Collectors.toList()); + ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List data) { + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch data!")); + } + }); } private boolean validateSessionMetadata(PluginContext ctx, PluginWebsocketSessionRef sessionRef, SubscriptionCmd cmd, String sessionId) { From 2accabf0b04db16734d5eeab02fd882abe3c608d Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 21 Feb 2017 17:30:24 +0200 Subject: [PATCH 04/14] Tmp commit --- .../device/DeviceActorMessageProcessor.java | 3 +- .../plugin/PluginProcessingContext.java | 111 +++++++++++------ .../server/dao/AbstractAsyncDao.java | 42 +++++++ .../server/dao/attributes/AttributesDao.java | 13 +- .../dao/attributes/AttributesService.java | 10 +- .../dao/attributes/BaseAttributesDao.java | 63 +++++++--- .../dao/attributes/BaseAttributesService.java | 19 ++- .../server/dao/device/DeviceService.java | 3 + .../dao/timeseries/BaseTimeseriesDao.java | 11 +- .../attributes/BaseAttributesServiceTest.java | 18 +-- .../dao/timeseries/TimeseriesServiceTest.java | 24 ++-- .../test/resources/cassandra-test.properties | 2 +- .../extensions/api/plugins/PluginContext.java | 16 ++- .../plugin/telemetry/SubscriptionManager.java | 51 +++++--- .../telemetry/handlers/BiPluginCallBack.java | 74 +++++++++++ .../handlers/TelemetryRestMsgHandler.java | 115 +++++++++++++----- .../handlers/TelemetryRuleMsgHandler.java | 35 ++++-- .../TelemetryWebsocketMsgHandler.java | 70 +++++++---- 18 files changed, 501 insertions(+), 179 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/AbstractAsyncDao.java create mode 100644 extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/BiPluginCallBack.java diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 70bb4f2421..f9c4e6f5c0 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -195,9 +195,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } void processAttributesUpdate(ActorContext context, DeviceAttributesEventNotificationMsg msg) { - //TODO: improve this procedure to fetch only changed attributes. + //TODO: improve this procedure to fetch only changed attributes and support attributes deletion refreshAttributes(); - //TODO: support attributes deletion Set keys = msg.getKeys(); if (attributeSubscriptions.size() > 0) { ToDeviceMsg notification = null; diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java index 9474a621e1..98149c296a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - * + *

+ * 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. @@ -56,6 +56,7 @@ import org.thingsboard.server.extensions.api.plugins.ws.PluginWebsocketSessionRe import org.thingsboard.server.extensions.api.plugins.ws.msg.PluginWebsocketMsg; import akka.actor.ActorRef; +import org.w3c.dom.Attr; import javax.annotation.Nullable; @@ -91,49 +92,86 @@ public final class PluginProcessingContext implements PluginContext { @Override public void saveAttributes(DeviceId deviceId, String scope, List attributes, PluginCallback callback) { validate(deviceId); - Set keys = new HashSet<>(); - for (AttributeKvEntry attribute : attributes) { - keys.add(new AttributeKey(scope, attribute.getKey())); - } ListenableFuture> rsListFuture = pluginCtx.attributesService.save(deviceId, scope, attributes); Futures.addCallback(rsListFuture, getListCallback(callback, v -> { - onDeviceAttributesChanged(deviceId, keys); + onDeviceAttributesChanged(deviceId, scope, attributes); return null; }), executor); } @Override - public Optional loadAttribute(DeviceId deviceId, String attributeType, String attributeKey) { + public void removeAttributes(DeviceId deviceId, String scope, List keys, PluginCallback callback) { validate(deviceId); - AttributeKvEntry attribute = pluginCtx.attributesService.find(deviceId, attributeType, attributeKey); - return Optional.ofNullable(attribute); + ListenableFuture> future = pluginCtx.attributesService.removeAll(deviceId, scope, keys); + Futures.addCallback(future, getCallback(callback, v -> null), executor); + onDeviceAttributesDeleted(tenantId, deviceId, keys.stream().map(key -> new AttributeKey(scope, key)).collect(Collectors.toSet())); } @Override - public List loadAttributes(DeviceId deviceId, String attributeType, List attributeKeys) { + public void saveAttributesByDevice(TenantId tenantId, DeviceId deviceId, String scope, List attributes, PluginCallback callback) { validate(deviceId); - List result = new ArrayList<>(attributeKeys.size()); - for (String attributeKey : attributeKeys) { - AttributeKvEntry attribute = pluginCtx.attributesService.find(deviceId, attributeType, attributeKey); - if (attribute != null) { - result.add(attribute); - } - } - return result; + + ListenableFuture> rsListFuture = pluginCtx.attributesService.save(deviceId, scope, attributes); + Futures.addCallback(rsListFuture, getListCallback(callback, v -> { + onDeviceAttributesChanged(tenantId, deviceId, scope, attributes); + return null; + }), executor); + } + + @Override + public void removeAttributesByDevice(TenantId tenantId, DeviceId deviceId, String scope, List keys, PluginCallback callback) { + validate(deviceId); + ListenableFuture> future = pluginCtx.attributesService.removeAll(deviceId, scope, keys); + Futures.addCallback(future, getCallback(callback, v -> null), executor); + onDeviceAttributesDeleted(tenantId, deviceId, keys.stream().map(key -> new AttributeKey(scope, key)).collect(Collectors.toSet())); + } + + @Override + public void loadAttribute(DeviceId deviceId, String attributeType, String attributeKey, PluginCallback> callback) { + validate(deviceId); + ListenableFuture> future = pluginCtx.attributesService.find(deviceId, attributeType, attributeKey); + Futures.addCallback(future, getCallback(callback, v -> v), executor); } @Override - public List loadAttributes(DeviceId deviceId, String attributeType) { + public void loadAttributes(DeviceId deviceId, String attributeType, Collection attributeKeys, PluginCallback> callback) { validate(deviceId); - return pluginCtx.attributesService.findAll(deviceId, attributeType); + ListenableFuture> future = pluginCtx.attributesService.find(deviceId, attributeType, attributeKeys); + Futures.addCallback(future, getCallback(callback, v -> v), executor); } @Override - public void removeAttributes(DeviceId deviceId, String scope, List keys) { + public void loadAttributes(DeviceId deviceId, String attributeType, PluginCallback> callback) { validate(deviceId); - pluginCtx.attributesService.removeAll(deviceId, scope, keys); - onDeviceAttributesDeleted(deviceId, keys.stream().map(key -> new AttributeKey(scope, key)).collect(Collectors.toSet())); + ListenableFuture> future = pluginCtx.attributesService.findAll(deviceId, attributeType); + Futures.addCallback(future, getCallback(callback, v -> v), executor); + } + + @Override + public void loadAttributes(DeviceId deviceId, Collection attributeTypes, PluginCallback> callback) { + validate(deviceId); + List>> futures = new ArrayList<>(); + attributeTypes.forEach(attributeType -> futures.add(pluginCtx.attributesService.findAll(deviceId, attributeType))); + convertFuturesAndAddCallback(callback, futures); + } + + @Override + public void loadAttributes(DeviceId deviceId, Collection attributeTypes, Collection attributeKeys, PluginCallback> callback) { + validate(deviceId); + List>> futures = new ArrayList<>(); + attributeTypes.forEach(attributeType -> futures.add(pluginCtx.attributesService.find(deviceId, attributeType, attributeKeys))); + convertFuturesAndAddCallback(callback, futures); + } + + private void convertFuturesAndAddCallback(PluginCallback> callback, List>> futures) { + ListenableFuture> future = Futures.transform(Futures.successfulAsList(futures), + (Function>, ? extends List>) input -> { + List result = new ArrayList<>(); + input.forEach(r -> result.addAll(r)); + return result; + }, executor); + Futures.addCallback(future, getCallback(callback, v -> v), executor); } @Override @@ -205,18 +243,12 @@ public final class PluginProcessingContext implements PluginContext { return securityCtx; } - private void onDeviceAttributesChanged(DeviceId deviceId, AttributeKey key) { - onDeviceAttributesChanged(deviceId, Collections.singleton(key)); + private void onDeviceAttributesDeleted(TenantId tenantId, DeviceId deviceId, Set keys) { + pluginCtx.toDeviceActor(DeviceAttributesEventNotificationMsg.onDelete(tenantId, deviceId, keys)); } - private void onDeviceAttributesDeleted(DeviceId deviceId, Set keys) { - Device device = pluginCtx.deviceService.findDeviceById(deviceId); - pluginCtx.toDeviceActor(DeviceAttributesEventNotificationMsg.onDelete(device.getTenantId(), deviceId, keys)); - } - - private void onDeviceAttributesChanged(DeviceId deviceId, Set keys) { - Device device = pluginCtx.deviceService.findDeviceById(deviceId); - pluginCtx.toDeviceActor(DeviceAttributesEventNotificationMsg.onUpdate(device.getTenantId(), deviceId, keys)); + private void onDeviceAttributesChanged(TenantId tenantId, DeviceId deviceId, String scope, List values) { + pluginCtx.toDeviceActor(DeviceAttributesEventNotificationMsg.onUpdate(tenantId, deviceId, scope, values)); } private FutureCallback> getListCallback(final PluginCallback callback, Function, T> transformer) { @@ -256,11 +288,12 @@ public final class PluginProcessingContext implements PluginContext { } // TODO: replace with our own exceptions - private boolean validate(DeviceId deviceId) { + private boolean validate(DeviceId deviceId, PluginCallback callback) { if (securityCtx.isPresent()) { - PluginApiCallSecurityContext ctx = securityCtx.get(); + final PluginApiCallSecurityContext ctx = securityCtx.get(); if (ctx.isTenantAdmin() || ctx.isCustomerUser()) { - Device device = pluginCtx.deviceService.findDeviceById(deviceId); + ListenableFuture device = pluginCtx.deviceService.findDeviceById(deviceId); + Futures.addCallback(device, ); if (device == null) { throw new IllegalStateException("Device not found!"); } else { diff --git a/dao/src/main/java/org/thingsboard/server/dao/AbstractAsyncDao.java b/dao/src/main/java/org/thingsboard/server/dao/AbstractAsyncDao.java new file mode 100644 index 0000000000..9b9368d45d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/AbstractAsyncDao.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 21.02.17. + */ +public abstract class AbstractAsyncDao extends AbstractDao { + + protected ExecutorService readResultsProcessingExecutor; + + @PostConstruct + public void startExecutor() { + readResultsProcessingExecutor = Executors.newCachedThreadPool(); + } + + @PreDestroy + public void stopExecutor() { + if (readResultsProcessingExecutor != null) { + readResultsProcessingExecutor.shutdownNow(); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index ead2c044cc..ae58d4d9c6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -15,23 +15,28 @@ */ package org.thingsboard.server.dao.attributes; +import com.datastax.driver.core.ResultSet; import com.datastax.driver.core.ResultSetFuture; +import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import java.util.Collection; import java.util.List; -import java.util.UUID; +import java.util.Optional; /** * @author Andrew Shvayka */ public interface AttributesDao { - AttributeKvEntry find(EntityId entityId, String attributeType, String attributeKey); + ListenableFuture> find(EntityId entityId, String attributeType, String attributeKey); - List findAll(EntityId entityId, String attributeType); + ListenableFuture> find(EntityId entityId, String attributeType, Collection attributeKey); + + ListenableFuture> findAll(EntityId entityId, String attributeType); ResultSetFuture save(EntityId entityId, String attributeType, AttributeKvEntry attribute); - void removeAll(EntityId entityId, String scope, List keys); + ListenableFuture> removeAll(EntityId entityId, String scope, List keys); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java index 5a1fd70bc7..6bf9fb2bd5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java @@ -23,18 +23,22 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import java.util.Collection; import java.util.List; +import java.util.Optional; /** * @author Andrew Shvayka */ public interface AttributesService { - AttributeKvEntry find(EntityId entityId, String scope, String attributeKey); + ListenableFuture> find(EntityId entityId, String scope, String attributeKey); - List findAll(EntityId entityId, String scope); + ListenableFuture> find(EntityId entityId, String scope, Collection attributeKeys); + + ListenableFuture> findAll(EntityId entityId, String scope); ListenableFuture> save(EntityId entityId, String scope, List attributes); - void removeAll(EntityId entityId, String scope, List attributeKeys); + ListenableFuture> removeAll(EntityId entityId, String scope, List attributeKeys); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java index 5148a121de..262d15d1a3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java @@ -18,19 +18,24 @@ package org.thingsboard.server.dao.attributes; import com.datastax.driver.core.*; import com.datastax.driver.core.querybuilder.QueryBuilder; import com.datastax.driver.core.querybuilder.Select; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.kv.DataType; -import org.thingsboard.server.dao.AbstractDao; +import org.thingsboard.server.dao.AbstractAsyncDao; import org.thingsboard.server.dao.model.ModelConstants; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.thingsboard.server.common.data.kv.*; import org.thingsboard.server.dao.timeseries.BaseTimeseriesDao; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; import static org.thingsboard.server.dao.model.ModelConstants.*; import static com.datastax.driver.core.querybuilder.QueryBuilder.*; @@ -40,29 +45,55 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.*; */ @Component @Slf4j -public class BaseAttributesDao extends AbstractDao implements AttributesDao { - +public class BaseAttributesDao extends AbstractAsyncDao implements AttributesDao { + private PreparedStatement saveStmt; + @PostConstruct + public void init() { + super.startExecutor(); + } + + @PreDestroy + public void stop() { + super.stopExecutor(); + } + @Override - public AttributeKvEntry find(EntityId entityId, String attributeType, String attributeKey) { + public ListenableFuture> find(EntityId entityId, String attributeType, String attributeKey) { Select.Where select = select().from(ATTRIBUTES_KV_CF) .where(eq(ENTITY_TYPE_COLUMN, entityId.getEntityType())) .and(eq(ENTITY_ID_COLUMN, entityId.getId())) .and(eq(ATTRIBUTE_TYPE_COLUMN, attributeType)) .and(eq(ATTRIBUTE_KEY_COLUMN, attributeKey)); log.trace("Generated query [{}] for entityId {} and key {}", select, entityId, attributeKey); - return convertResultToAttributesKvEntry(attributeKey, executeRead(select).one()); + return Futures.transform(executeAsyncRead(select), (Function>) input -> + Optional.of(convertResultToAttributesKvEntry(attributeKey, input.one())) + , readResultsProcessingExecutor); } @Override - public List findAll(EntityId entityId, String attributeType) { + public ListenableFuture> find(EntityId entityId, String attributeType, Collection attributeKeys) { + List>> entries = new ArrayList<>(); + attributeKeys.forEach(attributeKey -> entries.add(find(entityId, attributeType, attributeKey))); + return Futures.transform(Futures.allAsList(entries), (Function>, ? extends List>) input -> { + List result = new ArrayList<>(); + input.stream().filter(opt -> opt.isPresent()).forEach(opt -> result.add(opt.get())); + return result; + }, readResultsProcessingExecutor); + } + + + @Override + public ListenableFuture> findAll(EntityId entityId, String attributeType) { Select.Where select = select().from(ATTRIBUTES_KV_CF) .where(eq(ENTITY_TYPE_COLUMN, entityId.getEntityType())) .and(eq(ENTITY_ID_COLUMN, entityId.getId())) .and(eq(ATTRIBUTE_TYPE_COLUMN, attributeType)); log.trace("Generated query [{}] for entityId {} and attributeType {}", select, entityId, attributeType); - return convertResultToAttributesKvEntryList(executeRead(select)); + return Futures.transform(executeAsyncRead(select), (Function>) input -> + convertResultToAttributesKvEntryList(input) + , readResultsProcessingExecutor); } @Override @@ -93,20 +124,19 @@ public class BaseAttributesDao extends AbstractDao implements AttributesDao { } @Override - public void removeAll(EntityId entityId, String attributeType, List keys) { - for (String key : keys) { - delete(entityId, attributeType, key); - } + public ListenableFuture> removeAll(EntityId entityId, String attributeType, List keys) { + List futures = keys.stream().map(key -> delete(entityId, attributeType, key)).collect(Collectors.toList()); + return Futures.allAsList(futures); } - private void delete(EntityId entityId, String attributeType, String key) { + private ResultSetFuture delete(EntityId entityId, String attributeType, String key) { Statement delete = QueryBuilder.delete().all().from(ModelConstants.ATTRIBUTES_KV_CF) .where(eq(ENTITY_TYPE_COLUMN, entityId.getEntityType())) .and(eq(ENTITY_ID_COLUMN, entityId.getId())) .and(eq(ATTRIBUTE_TYPE_COLUMN, attributeType)) .and(eq(ATTRIBUTE_KEY_COLUMN, key)); log.debug("Remove request: {}", delete.toString()); - getSession().execute(delete); + return getSession().executeAsync(delete); } private PreparedStatement getSaveStmt() { @@ -150,5 +180,4 @@ public class BaseAttributesDao extends AbstractDao implements AttributesDao { } return entries; } - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index d3a1cb3550..43612419d0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -27,7 +27,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.service.Validator; +import java.util.Collection; import java.util.List; +import java.util.Optional; /** * @author Andrew Shvayka @@ -39,14 +41,21 @@ public class BaseAttributesService implements AttributesService { private AttributesDao attributesDao; @Override - public AttributeKvEntry find(EntityId entityId, String scope, String attributeKey) { + public ListenableFuture> find(EntityId entityId, String scope, String attributeKey) { validate(entityId, scope); Validator.validateString(attributeKey, "Incorrect attribute key " + attributeKey); return attributesDao.find(entityId, scope, attributeKey); } @Override - public List findAll(EntityId entityId, String scope) { + public ListenableFuture> find(EntityId entityId, String scope, Collection attributeKeys) { + validate(entityId, scope); + attributeKeys.forEach(attributeKey -> Validator.validateString(attributeKey, "Incorrect attribute key " + attributeKey)); + return attributesDao.find(entityId, scope, attributeKeys); + } + + @Override + public ListenableFuture> findAll(EntityId entityId, String scope) { validate(entityId, scope); return attributesDao.findAll(entityId, scope); } @@ -56,16 +65,16 @@ public class BaseAttributesService implements AttributesService { validate(entityId, scope); attributes.forEach(attribute -> validate(attribute)); List futures = Lists.newArrayListWithExpectedSize(attributes.size()); - for(AttributeKvEntry attribute : attributes) { + for (AttributeKvEntry attribute : attributes) { futures.add(attributesDao.save(entityId, scope, attribute)); } return Futures.allAsList(futures); } @Override - public void removeAll(EntityId entityId, String scope, List keys) { + public ListenableFuture> removeAll(EntityId entityId, String scope, List keys) { validate(entityId, scope); - attributesDao.removeAll(entityId, scope, keys); + return attributesDao.removeAll(entityId, scope, keys); } private static void validate(EntityId id, String scope) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index 8d780b6aa1..b760f5597d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.device; +import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; @@ -28,6 +29,8 @@ public interface DeviceService { Device findDeviceById(DeviceId deviceId); + ListenableFuture findDeviceByIdAsync(DeviceId deviceId); + Optional findDeviceByTenantIdAndName(TenantId tenantId, String name); Device saveDevice(Device device); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 81bbdf9309..42fede47f8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -28,6 +28,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.kv.*; import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.dao.AbstractAsyncDao; import org.thingsboard.server.dao.AbstractDao; import org.thingsboard.server.dao.model.ModelConstants; @@ -50,7 +51,7 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.select; */ @Component @Slf4j -public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { +public class BaseTimeseriesDao extends AbstractAsyncDao implements TimeseriesDao { @Value("${cassandra.query.min_aggregation_step_ms}") private int minAggregationStepMs; @@ -60,8 +61,6 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { private TsPartitionDate tsFormat; - private ExecutorService readResultsProcessingExecutor; - private PreparedStatement partitionInsertStmt; private PreparedStatement[] latestInsertStmts; private PreparedStatement[] saveStmts; @@ -71,8 +70,8 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { @PostConstruct public void init() { + super.startExecutor(); getFetchStmt(Aggregation.NONE); - readResultsProcessingExecutor = Executors.newCachedThreadPool(); Optional partition = TsPartitionDate.parse(partitioning); if (partition.isPresent()) { tsFormat = partition.get(); @@ -84,9 +83,7 @@ public class BaseTimeseriesDao extends AbstractDao implements TimeseriesDao { @PreDestroy public void stop() { - if (readResultsProcessingExecutor != null) { - readResultsProcessingExecutor.shutdownNow(); - } + super.stopExecutor(); } @Override diff --git a/dao/src/test/java/org/thingsboard/server/dao/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/attributes/BaseAttributesServiceTest.java index 8fb5d86410..559a3130e8 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/attributes/BaseAttributesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/attributes/BaseAttributesServiceTest.java @@ -32,6 +32,7 @@ import org.springframework.beans.factory.annotation.Autowired; import java.util.Collections; import java.util.List; +import java.util.Optional; import static org.thingsboard.server.common.data.DataConstants.CLIENT_SCOPE; import static org.thingsboard.server.common.data.DataConstants.DEVICE; @@ -54,8 +55,9 @@ public class BaseAttributesServiceTest extends AbstractServiceTest { KvEntry attrValue = new StringDataEntry("attribute1", "value1"); AttributeKvEntry attr = new BaseAttributeKvEntry(attrValue, 42L); attributesService.save(deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attr)).get(); - AttributeKvEntry saved = attributesService.find(deviceId, DataConstants.CLIENT_SCOPE, attr.getKey()); - Assert.assertEquals(attr, saved); + Optional saved = attributesService.find(deviceId, DataConstants.CLIENT_SCOPE, attr.getKey()).get(); + Assert.assertTrue(saved.isPresent()); + Assert.assertEquals(attr, saved.get()); } @Test @@ -65,15 +67,17 @@ public class BaseAttributesServiceTest extends AbstractServiceTest { AttributeKvEntry attrOld = new BaseAttributeKvEntry(attrOldValue, 42L); attributesService.save(deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrOld)).get(); - AttributeKvEntry saved = attributesService.find(deviceId, DataConstants.CLIENT_SCOPE, attrOld.getKey()); - Assert.assertEquals(attrOld, saved); + Optional saved = attributesService.find(deviceId, DataConstants.CLIENT_SCOPE, attrOld.getKey()).get(); + + Assert.assertTrue(saved.isPresent()); + Assert.assertEquals(attrOld, saved.get()); KvEntry attrNewValue = new StringDataEntry("attribute1", "value2"); AttributeKvEntry attrNew = new BaseAttributeKvEntry(attrNewValue, 73L); attributesService.save(deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrNew)).get(); - saved = attributesService.find(deviceId, DataConstants.CLIENT_SCOPE, attrOld.getKey()); - Assert.assertEquals(attrNew, saved); + saved = attributesService.find(deviceId, DataConstants.CLIENT_SCOPE, attrOld.getKey()).get(); + Assert.assertEquals(attrNew, saved.get()); } @Test @@ -91,7 +95,7 @@ public class BaseAttributesServiceTest extends AbstractServiceTest { attributesService.save(deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrANew)).get(); attributesService.save(deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrBNew)).get(); - List saved = attributesService.findAll(deviceId, DataConstants.CLIENT_SCOPE); + List saved = attributesService.findAll(deviceId, DataConstants.CLIENT_SCOPE).get(); Assert.assertNotNull(saved); Assert.assertEquals(2, saved.size()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java index 13c25c3213..fd16b75f95 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java @@ -114,8 +114,8 @@ public class TimeseriesServiceTest extends AbstractServiceTest { entries.add(save(deviceId, 45000, 500)); entries.add(save(deviceId, 55000, 600)); - List list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.NONE)).get(); + List list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.NONE))).get(); assertEquals(3, list.size()); assertEquals(55000, list.get(0).getTs()); assertEquals(java.util.Optional.of(600L), list.get(0).getLongValue()); @@ -126,8 +126,8 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(35000, list.get(2).getTs()); assertEquals(java.util.Optional.of(400L), list.get(2).getLongValue()); - list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.AVG)).get(); + list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.AVG))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); assertEquals(java.util.Optional.of(150L), list.get(0).getLongValue()); @@ -138,8 +138,8 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(50000, list.get(2).getTs()); assertEquals(java.util.Optional.of(550L), list.get(2).getLongValue()); - list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.SUM)).get(); + list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.SUM))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); @@ -151,8 +151,8 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(50000, list.get(2).getTs()); assertEquals(java.util.Optional.of(1100L), list.get(2).getLongValue()); - list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.MIN)).get(); + list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.MIN))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); @@ -164,8 +164,8 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(50000, list.get(2).getTs()); assertEquals(java.util.Optional.of(500L), list.get(2).getLongValue()); - list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.MAX)).get(); + list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.MAX))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); @@ -177,8 +177,8 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(50000, list.get(2).getTs()); assertEquals(java.util.Optional.of(600L), list.get(2).getLongValue()); - list = tsService.findAll(DataConstants.DEVICE, deviceId, new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.COUNT)).get(); + list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, + 60000, 3, Aggregation.COUNT))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); diff --git a/dao/src/test/resources/cassandra-test.properties b/dao/src/test/resources/cassandra-test.properties index 210d2c0c9e..a4eb3d0547 100644 --- a/dao/src/test/resources/cassandra-test.properties +++ b/dao/src/test/resources/cassandra-test.properties @@ -2,7 +2,7 @@ cassandra.cluster_name=Thingsboard Cluster cassandra.keyspace_name=thingsboard -cassandra.url=127.0.0.1:9042 +cassandra.url=127.0.0.1:9142 cassandra.ssl=false diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java index c2c5587704..64d4d535ea 100644 --- a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java @@ -94,13 +94,21 @@ public interface PluginContext { void saveAttributes(DeviceId deviceId, String attributeType, List attributes, PluginCallback callback); - Optional loadAttribute(DeviceId deviceId, String attributeType, String attributeKey); + void removeAttributes(DeviceId deviceId, String scope, List attributeKeys, PluginCallback callback); - List loadAttributes(DeviceId deviceId, String attributeType, List attributeKeys); + void saveAttributesByDevice(TenantId tenantId, DeviceId deviceId, String attributeType, List attributes, PluginCallback callback); - List loadAttributes(DeviceId deviceId, String attributeType); + void removeAttributesByDevice(TenantId tenantId, DeviceId deviceId, String scope, List attributeKeys, PluginCallback callback); - void removeAttributes(DeviceId deviceId, String scope, List attributeKeys); + void loadAttribute(DeviceId deviceId, String attributeType, String attributeKey, PluginCallback> callback); + + void loadAttributes(DeviceId deviceId, String attributeType, Collection attributeKeys, PluginCallback> callback); + + void loadAttributes(DeviceId deviceId, String attributeType, PluginCallback> callback); + + void loadAttributes(DeviceId deviceId, Collection attributeTypes, PluginCallback> callback); + + void loadAttributes(DeviceId deviceId, Collection attributeTypes, Collection attributeKeys, PluginCallback> callback); void getCustomerDevices(TenantId tenantId, CustomerId customerId, int limit, PluginCallback> callback); } diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java index 0624876b36..ea7a185b20 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.extensions.core.plugin.telemetry; +import com.sun.javafx.collections.MappingChange; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.kv.*; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.extensions.api.plugins.PluginCallback; import org.thingsboard.server.extensions.api.plugins.PluginContext; import org.thingsboard.server.extensions.core.plugin.telemetry.handlers.TelemetryRpcMsgHandler; import org.thingsboard.server.extensions.core.plugin.telemetry.handlers.TelemetryWebsocketMsgHandler; @@ -66,28 +68,49 @@ public class SubscriptionManager { DeviceId deviceId = subscription.getDeviceId(); log.trace("[{}] Registering remote subscription [{}] for device [{}] to [{}]", sessionId, subscription.getSubscriptionId(), deviceId, address); registerSubscription(sessionId, deviceId, subscription); - List missedUpdates = new ArrayList<>(); if (subscription.getType() == SubscriptionType.ATTRIBUTES) { - subscription.getKeyStates().entrySet().forEach(e -> { - Optional latestOpt = ctx.loadAttribute(deviceId, DataConstants.CLIENT_SCOPE, e.getKey()); - if (latestOpt.isPresent()) { - AttributeKvEntry latestEntry = latestOpt.get(); - if (latestEntry.getLastUpdateTs() > e.getValue()) { - missedUpdates.add(new BasicTsKvEntry(latestEntry.getLastUpdateTs(), latestEntry)); - } + final Map keyStates = subscription.getKeyStates(); + ctx.loadAttributes(deviceId, DataConstants.CLIENT_SCOPE, keyStates.keySet(), new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List values) { + List missedUpdates = new ArrayList<>(); + values.forEach(latestEntry -> { + if (latestEntry.getLastUpdateTs() > keyStates.get(latestEntry.getKey())) { + missedUpdates.add(new BasicTsKvEntry(latestEntry.getLastUpdateTs(), latestEntry)); } + }); + if (!missedUpdates.isEmpty()) { + rpcHandler.onSubscriptionUpdate(ctx, address, sessionId, new SubscriptionUpdate(subscription.getSubscriptionId(), missedUpdates)); } - ); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch missed updates.", e); + } + }); } else if (subscription.getType() == SubscriptionType.TIMESERIES) { long curTs = System.currentTimeMillis(); + List queries = new ArrayList<>(); subscription.getKeyStates().entrySet().forEach(e -> { - TsKvQuery query = new BaseTsKvQuery(e.getKey(), e.getValue() + 1L, curTs); - missedUpdates.addAll(ctx.loadTimeseries(deviceId, query)); + queries.add(new BaseTsKvQuery(e.getKey(), e.getValue() + 1L, curTs)); + }); + + ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List missedUpdates) { + if (!missedUpdates.isEmpty()) { + rpcHandler.onSubscriptionUpdate(ctx, address, sessionId, new SubscriptionUpdate(subscription.getSubscriptionId(), missedUpdates)); + } + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch missed updates.", e); + } }); } - if (!missedUpdates.isEmpty()) { - rpcHandler.onSubscriptionUpdate(ctx, address, sessionId, new SubscriptionUpdate(subscription.getSubscriptionId(), missedUpdates)); - } + } private void registerSubscription(String sessionId, DeviceId deviceId, Subscription subscription) { diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/BiPluginCallBack.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/BiPluginCallBack.java new file mode 100644 index 0000000000..bc5285caac --- /dev/null +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/BiPluginCallBack.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.extensions.core.plugin.telemetry.handlers; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.extensions.api.plugins.PluginCallback; +import org.thingsboard.server.extensions.api.plugins.PluginContext; + +/** + * Created by ashvayka on 21.02.17. + */ +@Slf4j +public abstract class BiPluginCallBack { + + private V1 v1; + private V2 v2; + + public PluginCallback getV1Callback() { + return new PluginCallback() { + @Override + public void onSuccess(PluginContext ctx, V1 value) { + synchronized (BiPluginCallBack.this) { + BiPluginCallBack.this.v1 = value; + if (v2 != null) { + BiPluginCallBack.this.onSuccess(ctx, v1, v2); + } + } + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + BiPluginCallBack.this.onFailure(ctx, e); + } + }; + } + + public PluginCallback getV2Callback() { + return new PluginCallback() { + @Override + public void onSuccess(PluginContext ctx, V2 value) { + synchronized (BiPluginCallBack.this) { + BiPluginCallBack.this.v2 = value; + if (v1 != null) { + BiPluginCallBack.this.onSuccess(ctx, v1, v2); + } + } + + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + BiPluginCallBack.this.onFailure(ctx, e); + } + }; + } + + abstract public void onSuccess(PluginContext ctx, V1 v1, V2 v2); + + abstract public void onFailure(PluginContext ctx, Exception e); + +} diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java index e93f0b5012..28d8b7c5ce 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java @@ -77,41 +77,58 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { } }); } else if (entity.equals("attributes")) { - List attributes; + PluginCallback> callback = getAttributeKeysPluginCallback(msg); if (!StringUtils.isEmpty(scope)) { - attributes = ctx.loadAttributes(deviceId, scope); + ctx.loadAttributes(deviceId, scope, callback); } else { - attributes = new ArrayList<>(); - Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> attributes.addAll(ctx.loadAttributes(deviceId, s))); + ctx.loadAttributes(deviceId, Arrays.asList(DataConstants.ALL_SCOPES), callback); } - List keys = attributes.stream().map(attrKv -> attrKv.getKey()).collect(Collectors.toList()); - msg.getResponseHolder().setResult(new ResponseEntity<>(keys, HttpStatus.OK)); } } else if (method.equals("values")) { if ("timeseries".equals(entity)) { - String keys = request.getParameter("keys"); + String keysStr = request.getParameter("keys"); Optional startTs = request.getLongParamValue("startTs"); Optional endTs = request.getLongParamValue("endTs"); Optional limit = request.getIntParamValue("limit"); - Map> data = new LinkedHashMap<>(); - for (String key : keys.split(",")) { - //TODO: refactoring -// List entries = ctx.loadTimeseries(deviceId, new BaseTsKvQuery(key, startTs, endTs, limit)); -// data.put(key, entries.stream().map(v -> new TsData(v.getTs(), v.getValueAsString())).collect(Collectors.toList())); - } - msg.getResponseHolder().setResult(new ResponseEntity<>(data, HttpStatus.OK)); + Aggregation agg = Aggregation.valueOf(request.getParameter("agg", Aggregation.NONE.name())); + + List keys = Arrays.asList(keysStr.split(",")); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs.get(), endTs.get(), limit.get(), agg)).collect(Collectors.toList()); + ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List data) { + Map> result = new LinkedHashMap<>(); + for (TsKvEntry entry : data) { + result.put(entry.getKey(), data.stream().map(v -> new TsData(v.getTs(), v.getValueAsString())).collect(Collectors.toList())); + } + msg.getResponseHolder().setResult(new ResponseEntity<>(data, HttpStatus.OK)); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch historical data", e); + msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }); } else if ("attributes".equals(entity)) { String keys = request.getParameter("keys", ""); - List attributes; + + PluginCallback> callback = getAttributeValuesPluginCallback(msg); if (!StringUtils.isEmpty(scope)) { - attributes = getAttributeKvEntries(ctx, scope, deviceId, keys); + if (!StringUtils.isEmpty(keys)) { + List keyList = Arrays.asList(keys.split(",")); + ctx.loadAttributes(deviceId, scope, keyList, callback); + } else { + ctx.loadAttributes(deviceId, scope, callback); + } } else { - attributes = new ArrayList<>(); - Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> attributes.addAll(getAttributeKvEntries(ctx, s, deviceId, keys))); + if (!StringUtils.isEmpty(keys)) { + List keyList = Arrays.asList(keys.split(",")); + ctx.loadAttributes(deviceId, Arrays.asList(DataConstants.ALL_SCOPES), keyList, callback); + } else { + ctx.loadAttributes(deviceId, Arrays.asList(DataConstants.ALL_SCOPES), callback); + } } - List values = attributes.stream().map(attribute -> new AttributeData(attribute.getLastUpdateTs(), - attribute.getKey(), attribute.getValue())).collect(Collectors.toList()); - msg.getResponseHolder().setResult(new ResponseEntity<>(values, HttpStatus.OK)); } } } else { @@ -156,6 +173,7 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to save attributes", e); msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); } }); @@ -184,8 +202,18 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { String keysParam = request.getParameter("keys"); if (!StringUtils.isEmpty(keysParam)) { String[] keys = keysParam.split(","); - ctx.removeAttributes(deviceId, scope, Arrays.asList(keys)); - msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.OK)); + ctx.removeAttributes(deviceId, scope, Arrays.asList(keys), new PluginCallback() { + @Override + public void onSuccess(PluginContext ctx, Void value) { + msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to remove attributes", e); + msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }); return; } } @@ -196,14 +224,37 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); } - private List getAttributeKvEntries(PluginContext ctx, String scope, DeviceId deviceId, String keysParam) { - List attributes; - if (!StringUtils.isEmpty(keysParam)) { - String[] keys = keysParam.split(","); - attributes = ctx.loadAttributes(deviceId, scope, Arrays.asList(keys)); - } else { - attributes = ctx.loadAttributes(deviceId, scope); - } - return attributes; + + private PluginCallback> getAttributeKeysPluginCallback(final PluginRestMsg msg) { + return new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List attributes) { + List keys = attributes.stream().map(attrKv -> attrKv.getKey()).collect(Collectors.toList()); + msg.getResponseHolder().setResult(new ResponseEntity<>(keys, HttpStatus.OK)); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch attributes", e); + msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }; + } + + private PluginCallback> getAttributeValuesPluginCallback(final PluginRestMsg msg) { + return new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List attributes) { + List values = attributes.stream().map(attribute -> new AttributeData(attribute.getLastUpdateTs(), + attribute.getKey(), attribute.getValue())).collect(Collectors.toList()); + msg.getResponseHolder().setResult(new ResponseEntity<>(values, HttpStatus.OK)); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch attributes", e); + msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }; } } diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java index 85a25da427..d9bfba073e 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.extensions.core.plugin.telemetry.handlers; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.RuleId; @@ -38,6 +39,7 @@ import org.thingsboard.server.extensions.core.plugin.telemetry.sub.SubscriptionT import java.util.*; import java.util.stream.Collectors; +@Slf4j public class TelemetryRuleMsgHandler extends DefaultRuleMsgHandler { private final SubscriptionManager subscriptionManager; @@ -49,27 +51,36 @@ public class TelemetryRuleMsgHandler extends DefaultRuleMsgHandler { public void handleGetAttributesRequest(PluginContext ctx, TenantId tenantId, RuleId ruleId, GetAttributesRequestRuleToPluginMsg msg) { GetAttributesRequest request = msg.getPayload(); - List clientAttributes = getAttributeKvEntries(ctx, msg.getDeviceId(), DataConstants.CLIENT_SCOPE, request.getClientAttributeNames()); - List sharedAttributes = getAttributeKvEntries(ctx, msg.getDeviceId(), DataConstants.SHARED_SCOPE, request.getSharedAttributeNames()); + BiPluginCallBack, List> callback = new BiPluginCallBack, List>() { - BasicGetAttributesResponse response = BasicGetAttributesResponse.onSuccess(request.getMsgType(), - request.getRequestId(), BasicAttributeKVMsg.from(clientAttributes, sharedAttributes)); + @Override + public void onSuccess(PluginContext ctx, List clientAttributes, List sharedAttributes) { + BasicGetAttributesResponse response = BasicGetAttributesResponse.onSuccess(request.getMsgType(), + request.getRequestId(), BasicAttributeKVMsg.from(clientAttributes, sharedAttributes)); + ctx.reply(new ResponsePluginToRuleMsg(msg.getUid(), tenantId, ruleId, response)); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to process get attributes request", e); + ctx.reply(new ResponsePluginToRuleMsg(msg.getUid(), tenantId, ruleId, BasicStatusCodeResponse.onError(request.getMsgType(), request.getRequestId(), e))); + } + }; - ctx.reply(new ResponsePluginToRuleMsg(msg.getUid(), tenantId, ruleId, response)); + getAttributeKvEntries(ctx, msg.getDeviceId(), DataConstants.CLIENT_SCOPE, request.getClientAttributeNames(), callback.getV1Callback()); + getAttributeKvEntries(ctx, msg.getDeviceId(), DataConstants.SHARED_SCOPE, request.getSharedAttributeNames(), callback.getV2Callback()); } - private List getAttributeKvEntries(PluginContext ctx, DeviceId deviceId, String scope, Optional> names) { - List attributes; + private void getAttributeKvEntries(PluginContext ctx, DeviceId deviceId, String scope, Optional> names, PluginCallback> callback) { if (names.isPresent()) { if (!names.get().isEmpty()) { - attributes = ctx.loadAttributes(deviceId, scope, new ArrayList<>(names.get())); + ctx.loadAttributes(deviceId, scope, new ArrayList<>(names.get()), callback); } else { - attributes = ctx.loadAttributes(deviceId, scope); + ctx.loadAttributes(deviceId, scope, callback); } } else { - attributes = Collections.emptyList(); + callback.onSuccess(ctx, Collections.emptyList()); } - return attributes; } @Override @@ -100,6 +111,7 @@ public class TelemetryRuleMsgHandler extends DefaultRuleMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to process telemetry upload request", e); ctx.reply(new ResponsePluginToRuleMsg(msg.getUid(), tenantId, ruleId, BasicStatusCodeResponse.onError(request.getMsgType(), request.getRequestId(), e))); } }); @@ -127,6 +139,7 @@ public class TelemetryRuleMsgHandler extends DefaultRuleMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to process attributes update request", e); ctx.reply(new ResponsePluginToRuleMsg(msg.getUid(), tenantId, ruleId, BasicStatusCodeResponse.onError(request.getMsgType(), request.getRequestId(), e))); } }); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index 739bedfd89..dd30f99e9c 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -104,37 +104,64 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { SubscriptionState sub; if (keysOptional.isPresent()) { List keys = new ArrayList<>(keysOptional.get()); - List data = new ArrayList<>(); + + PluginCallback> callback = new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List data) { + List attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList()); + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), attributesData)); + + Map subState = new HashMap<>(keys.size()); + keys.forEach(key -> subState.put(key, 0L)); + attributesData.forEach(v -> subState.put(v.getKey(), v.getTs())); + + SubscriptionState sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.ATTRIBUTES, false, subState); + subscriptionManager.addLocalWsSubscription(ctx, sessionId, deviceId, sub); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch attributes!", e); + SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch attributes!"); + sendWsMsg(ctx, sessionRef, update); + } + }; + if (StringUtils.isEmpty(cmd.getScope())) { - Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> data.addAll(ctx.loadAttributes(deviceId, s, keys))); + ctx.loadAttributes(deviceId, Arrays.asList(DataConstants.ALL_SCOPES), keys, callback); } else { - data.addAll(ctx.loadAttributes(deviceId, cmd.getScope(), keys)); + ctx.loadAttributes(deviceId, cmd.getScope(), keys, callback); } + } else { + PluginCallback> callback = new PluginCallback>() { + @Override + public void onSuccess(PluginContext ctx, List data) { + List attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList()); + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), attributesData)); - List attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList()); - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), attributesData)); + Map subState = new HashMap<>(attributesData.size()); + attributesData.forEach(v -> subState.put(v.getKey(), v.getTs())); - Map subState = new HashMap<>(keys.size()); - keys.forEach(key -> subState.put(key, 0L)); - attributesData.forEach(v -> subState.put(v.getKey(), v.getTs())); + SubscriptionState sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.ATTRIBUTES, true, subState); + subscriptionManager.addLocalWsSubscription(ctx, sessionId, deviceId, sub); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch attributes!", e); + SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch attributes!"); + sendWsMsg(ctx, sessionRef, update); + } + }; - sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.ATTRIBUTES, false, subState); - } else { - List data = new ArrayList<>(); if (StringUtils.isEmpty(cmd.getScope())) { - Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> data.addAll(ctx.loadAttributes(deviceId, s))); + ctx.loadAttributes(deviceId, Arrays.asList(DataConstants.ALL_SCOPES), callback); } else { - data.addAll(ctx.loadAttributes(deviceId, cmd.getScope())); + ctx.loadAttributes(deviceId, cmd.getScope(), callback); } - List attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList()); - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), attributesData)); - - Map subState = new HashMap<>(attributesData.size()); - attributesData.forEach(v -> subState.put(v.getKey(), v.getTs())); - - sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.ATTRIBUTES, true, subState); } - subscriptionManager.addLocalWsSubscription(ctx, sessionId, deviceId, sub); } } } @@ -205,6 +232,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { + log.error("Failed to fetch data!", e); SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, "Failed to fetch data!"); sendWsMsg(ctx, sessionRef, update); From 301f106e540213e6dcfc2514531f5ccdcc83a555 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Tue, 21 Feb 2017 19:29:00 +0200 Subject: [PATCH 05/14] Major refactoring of plugin context --- .../plugin/PluginProcessingContext.java | 236 +++++++++--------- .../actors/plugin/ValidationCallback.java | 49 ++++ .../server/dao/AbstractModelDao.java | 34 +++ .../java/org/thingsboard/server/dao/Dao.java | 3 + .../server/dao/device/DeviceServiceImpl.java | 11 + .../api/exception/UnauthorizedException.java | 22 ++ .../extensions/api/plugins/PluginContext.java | 10 +- .../rpc/handlers/RpcRestMsgHandler.java | 42 ++-- .../handlers/TelemetryRestMsgHandler.java | 8 +- .../handlers/TelemetryRuleMsgHandler.java | 2 +- .../TelemetryWebsocketMsgHandler.java | 45 ++-- 11 files changed, 294 insertions(+), 168 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/actors/plugin/ValidationCallback.java create mode 100644 extensions-api/src/main/java/org/thingsboard/server/extensions/api/exception/UnauthorizedException.java diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java index 98149c296a..b102226921 100644 --- a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java @@ -1,12 +1,12 @@ /** * Copyright © 2016-2017 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 - *

+ * + * 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. @@ -90,133 +90,115 @@ public final class PluginProcessingContext implements PluginContext { } @Override - public void saveAttributes(DeviceId deviceId, String scope, List attributes, PluginCallback callback) { - validate(deviceId); - - ListenableFuture> rsListFuture = pluginCtx.attributesService.save(deviceId, scope, attributes); - Futures.addCallback(rsListFuture, getListCallback(callback, v -> { - onDeviceAttributesChanged(deviceId, scope, attributes); - return null; - }), executor); + public void saveAttributes(final TenantId tenantId, final DeviceId deviceId, final String scope, final List attributes, final PluginCallback callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> rsListFuture = pluginCtx.attributesService.save(deviceId, scope, attributes); + Futures.addCallback(rsListFuture, getListCallback(callback, v -> { + onDeviceAttributesChanged(tenantId, deviceId, scope, attributes); + return null; + }), executor); + })); } @Override - public void removeAttributes(DeviceId deviceId, String scope, List keys, PluginCallback callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.attributesService.removeAll(deviceId, scope, keys); - Futures.addCallback(future, getCallback(callback, v -> null), executor); - onDeviceAttributesDeleted(tenantId, deviceId, keys.stream().map(key -> new AttributeKey(scope, key)).collect(Collectors.toSet())); + public void removeAttributes(final TenantId tenantId, final DeviceId deviceId, final String scope, final List keys, final PluginCallback callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> future = pluginCtx.attributesService.removeAll(deviceId, scope, keys); + Futures.addCallback(future, getCallback(callback, v -> null), executor); + onDeviceAttributesDeleted(tenantId, deviceId, keys.stream().map(key -> new AttributeKey(scope, key)).collect(Collectors.toSet())); + })); } @Override - public void saveAttributesByDevice(TenantId tenantId, DeviceId deviceId, String scope, List attributes, PluginCallback callback) { - validate(deviceId); - - ListenableFuture> rsListFuture = pluginCtx.attributesService.save(deviceId, scope, attributes); - Futures.addCallback(rsListFuture, getListCallback(callback, v -> { - onDeviceAttributesChanged(tenantId, deviceId, scope, attributes); - return null; - }), executor); + public void loadAttribute(DeviceId deviceId, String attributeType, String attributeKey, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> future = pluginCtx.attributesService.find(deviceId, attributeType, attributeKey); + Futures.addCallback(future, getCallback(callback, v -> v), executor); + })); } @Override - public void removeAttributesByDevice(TenantId tenantId, DeviceId deviceId, String scope, List keys, PluginCallback callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.attributesService.removeAll(deviceId, scope, keys); - Futures.addCallback(future, getCallback(callback, v -> null), executor); - onDeviceAttributesDeleted(tenantId, deviceId, keys.stream().map(key -> new AttributeKey(scope, key)).collect(Collectors.toSet())); - } - - @Override - public void loadAttribute(DeviceId deviceId, String attributeType, String attributeKey, PluginCallback> callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.attributesService.find(deviceId, attributeType, attributeKey); - Futures.addCallback(future, getCallback(callback, v -> v), executor); - } - - @Override - public void loadAttributes(DeviceId deviceId, String attributeType, Collection attributeKeys, PluginCallback> callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.attributesService.find(deviceId, attributeType, attributeKeys); - Futures.addCallback(future, getCallback(callback, v -> v), executor); + public void loadAttributes(DeviceId deviceId, String attributeType, Collection attributeKeys, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> future = pluginCtx.attributesService.find(deviceId, attributeType, attributeKeys); + Futures.addCallback(future, getCallback(callback, v -> v), executor); + })); } @Override public void loadAttributes(DeviceId deviceId, String attributeType, PluginCallback> callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.attributesService.findAll(deviceId, attributeType); - Futures.addCallback(future, getCallback(callback, v -> v), executor); + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> future = pluginCtx.attributesService.findAll(deviceId, attributeType); + Futures.addCallback(future, getCallback(callback, v -> v), executor); + })); } @Override - public void loadAttributes(DeviceId deviceId, Collection attributeTypes, PluginCallback> callback) { - validate(deviceId); - List>> futures = new ArrayList<>(); - attributeTypes.forEach(attributeType -> futures.add(pluginCtx.attributesService.findAll(deviceId, attributeType))); - convertFuturesAndAddCallback(callback, futures); + public void loadAttributes(final DeviceId deviceId, final Collection attributeTypes, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + List>> futures = new ArrayList<>(); + attributeTypes.forEach(attributeType -> futures.add(pluginCtx.attributesService.findAll(deviceId, attributeType))); + convertFuturesAndAddCallback(callback, futures); + })); } @Override - public void loadAttributes(DeviceId deviceId, Collection attributeTypes, Collection attributeKeys, PluginCallback> callback) { - validate(deviceId); - List>> futures = new ArrayList<>(); - attributeTypes.forEach(attributeType -> futures.add(pluginCtx.attributesService.find(deviceId, attributeType, attributeKeys))); - convertFuturesAndAddCallback(callback, futures); - } - - private void convertFuturesAndAddCallback(PluginCallback> callback, List>> futures) { - ListenableFuture> future = Futures.transform(Futures.successfulAsList(futures), - (Function>, ? extends List>) input -> { - List result = new ArrayList<>(); - input.forEach(r -> result.addAll(r)); - return result; - }, executor); - Futures.addCallback(future, getCallback(callback, v -> v), executor); + public void loadAttributes(final DeviceId deviceId, final Collection attributeTypes, final Collection attributeKeys, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + List>> futures = new ArrayList<>(); + attributeTypes.forEach(attributeType -> futures.add(pluginCtx.attributesService.find(deviceId, attributeType, attributeKeys))); + convertFuturesAndAddCallback(callback, futures); + })); } @Override - public void saveTsData(DeviceId deviceId, TsKvEntry entry, PluginCallback callback) { - validate(deviceId); - ListenableFuture> rsListFuture = pluginCtx.tsService.save(DataConstants.DEVICE, deviceId, entry); - Futures.addCallback(rsListFuture, getListCallback(callback, v -> null), executor); + public void saveTsData(final DeviceId deviceId, final TsKvEntry entry, final PluginCallback callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> rsListFuture = pluginCtx.tsService.save(DataConstants.DEVICE, deviceId, entry); + Futures.addCallback(rsListFuture, getListCallback(callback, v -> null), executor); + })); } @Override - public void saveTsData(DeviceId deviceId, List entries, PluginCallback callback) { - validate(deviceId); - ListenableFuture> rsListFuture = pluginCtx.tsService.save(DataConstants.DEVICE, deviceId, entries); - Futures.addCallback(rsListFuture, getListCallback(callback, v -> null), executor); + public void saveTsData(final DeviceId deviceId, final List entries, final PluginCallback callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> rsListFuture = pluginCtx.tsService.save(DataConstants.DEVICE, deviceId, entries); + Futures.addCallback(rsListFuture, getListCallback(callback, v -> null), executor); + })); } @Override - public void loadTimeseries(DeviceId deviceId, List queries, PluginCallback> callback) { - validate(deviceId); - ListenableFuture> future = pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, queries); - Futures.addCallback(future, getCallback(callback, v -> v), executor); + public void loadTimeseries(final DeviceId deviceId, final List queries, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> future = pluginCtx.tsService.findAll(DataConstants.DEVICE, deviceId, queries); + Futures.addCallback(future, getCallback(callback, v -> v), executor); + })); } @Override - public void loadLatestTimeseries(DeviceId deviceId, PluginCallback> callback) { - validate(deviceId); - ResultSetFuture future = pluginCtx.tsService.findAllLatest(DataConstants.DEVICE, deviceId); - Futures.addCallback(future, getCallback(callback, pluginCtx.tsService::convertResultSetToTsKvEntryList), executor); + public void loadLatestTimeseries(final DeviceId deviceId, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ResultSetFuture future = pluginCtx.tsService.findAllLatest(DataConstants.DEVICE, deviceId); + Futures.addCallback(future, getCallback(callback, pluginCtx.tsService::convertResultSetToTsKvEntryList), executor); + })); } @Override - public void loadLatestTimeseries(DeviceId deviceId, Collection keys, PluginCallback> callback) { - validate(deviceId); - ListenableFuture> rsListFuture = pluginCtx.tsService.findLatest(DataConstants.DEVICE, deviceId, keys); - Futures.addCallback(rsListFuture, getListCallback(callback, rsList -> - { - List result = new ArrayList<>(); - for (ResultSet rs : rsList) { - Row row = rs.one(); - if (row != null) { - result.add(pluginCtx.tsService.convertResultToTsKvEntry(row)); + public void loadLatestTimeseries(final DeviceId deviceId, final Collection keys, final PluginCallback> callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> { + ListenableFuture> rsListFuture = pluginCtx.tsService.findLatest(DataConstants.DEVICE, deviceId, keys); + Futures.addCallback(rsListFuture, getListCallback(callback, rsList -> + { + List result = new ArrayList<>(); + for (ResultSet rs : rsList) { + Row row = rs.one(); + if (row != null) { + result.add(pluginCtx.tsService.convertResultToTsKvEntry(row)); + } } - } - return result; - }), executor); + return result; + }), executor); + })); } @Override @@ -224,15 +206,6 @@ public final class PluginProcessingContext implements PluginContext { pluginCtx.parentActor.tell(msg, ActorRef.noSender()); } - @Override - public boolean checkAccess(DeviceId deviceId) { - try { - return validate(deviceId); - } catch (IllegalStateException | IllegalArgumentException e) { - return false; - } - } - @Override public PluginId getPluginId() { return pluginCtx.pluginId; @@ -273,7 +246,11 @@ public final class PluginProcessingContext implements PluginContext { return new FutureCallback() { @Override public void onSuccess(@Nullable R result) { - pluginCtx.self().tell(PluginCallbackMessage.onSuccess(callback, transformer.apply(result)), ActorRef.noSender()); + try { + pluginCtx.self().tell(PluginCallbackMessage.onSuccess(callback, transformer.apply(result)), ActorRef.noSender()); + } catch (Exception e) { + pluginCtx.self().tell(PluginCallbackMessage.onError(callback, e), ActorRef.noSender()); + } } @Override @@ -287,27 +264,35 @@ public final class PluginProcessingContext implements PluginContext { }; } - // TODO: replace with our own exceptions - private boolean validate(DeviceId deviceId, PluginCallback callback) { + @Override + public void checkAccess(DeviceId deviceId, PluginCallback callback) { + validate(deviceId, new ValidationCallback(callback, ctx -> callback.onSuccess(ctx, null))); + } + + private void validate(DeviceId deviceId, ValidationCallback callback) { if (securityCtx.isPresent()) { final PluginApiCallSecurityContext ctx = securityCtx.get(); if (ctx.isTenantAdmin() || ctx.isCustomerUser()) { - ListenableFuture device = pluginCtx.deviceService.findDeviceById(deviceId); - Futures.addCallback(device, ); - if (device == null) { - throw new IllegalStateException("Device not found!"); - } else { - if (!device.getTenantId().equals(ctx.getTenantId())) { - throw new IllegalArgumentException("Device belongs to different tenant!"); - } else if (ctx.isCustomerUser() && !device.getCustomerId().equals(ctx.getCustomerId())) { - throw new IllegalArgumentException("Device belongs to different customer!"); + ListenableFuture deviceFuture = pluginCtx.deviceService.findDeviceByIdAsync(deviceId); + Futures.addCallback(deviceFuture, getCallback(callback, device -> { + if (device == null) { + return Boolean.FALSE; + } else { + if (!device.getTenantId().equals(ctx.getTenantId())) { + return Boolean.FALSE; + } else if (ctx.isCustomerUser() && !device.getCustomerId().equals(ctx.getCustomerId())) { + return Boolean.FALSE; + } else { + return Boolean.TRUE; + } } - } + })); } else { - return false; + callback.onSuccess(this, Boolean.FALSE); } + } else { + callback.onSuccess(this, Boolean.TRUE); } - return true; } @Override @@ -338,4 +323,15 @@ public final class PluginProcessingContext implements PluginContext { public void scheduleTimeoutMsg(TimeoutMsg msg) { pluginCtx.scheduleTimeoutMsg(msg); } + + + private void convertFuturesAndAddCallback(PluginCallback> callback, List>> futures) { + ListenableFuture> future = Futures.transform(Futures.successfulAsList(futures), + (Function>, ? extends List>) input -> { + List result = new ArrayList<>(); + input.forEach(r -> result.addAll(r)); + return result; + }, executor); + Futures.addCallback(future, getCallback(callback, v -> v), executor); + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/ValidationCallback.java b/application/src/main/java/org/thingsboard/server/actors/plugin/ValidationCallback.java new file mode 100644 index 0000000000..707afa50dd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/ValidationCallback.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.plugin; + +import com.hazelcast.util.function.Consumer; +import org.thingsboard.server.extensions.api.exception.UnauthorizedException; +import org.thingsboard.server.extensions.api.plugins.PluginCallback; +import org.thingsboard.server.extensions.api.plugins.PluginContext; + +/** + * Created by ashvayka on 21.02.17. + */ +public class ValidationCallback implements PluginCallback { + + private final PluginCallback callback; + private final Consumer action; + + public ValidationCallback(PluginCallback callback, Consumer action) { + this.callback = callback; + this.action = action; + } + + @Override + public void onSuccess(PluginContext ctx, Boolean value) { + if (value) { + action.accept(ctx); + } else { + onFailure(ctx, new UnauthorizedException()); + } + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + callback.onFailure(ctx, e); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/AbstractModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/AbstractModelDao.java index 73a553767c..60c833bf30 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/AbstractModelDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/AbstractModelDao.java @@ -16,17 +16,22 @@ package org.thingsboard.server.dao; import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; import com.datastax.driver.core.Statement; import com.datastax.driver.core.querybuilder.QueryBuilder; import com.datastax.driver.core.querybuilder.Select; import com.datastax.driver.core.utils.UUIDs; import com.datastax.driver.mapping.Mapper; import com.datastax.driver.mapping.Result; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.wrapper.EntityResultSet; import org.thingsboard.server.dao.model.ModelConstants; +import javax.annotation.Nullable; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -72,6 +77,27 @@ public abstract class AbstractModelDao> extends Abstract return object; } + protected ListenableFuture findOneByStatementAsync(Statement statement) { + if (statement != null) { + statement.setConsistencyLevel(cluster.getDefaultReadConsistencyLevel()); + ResultSetFuture resultSetFuture = getSession().executeAsync(statement); + ListenableFuture result = Futures.transform(resultSetFuture, new Function() { + @Nullable + @Override + public T apply(@Nullable ResultSet resultSet) { + Result result = getMapper().map(resultSet); + if (result != null) { + return result.one(); + } else { + return null; + } + } + }); + return result; + } + return Futures.immediateFuture(null); + } + protected Statement getSaveQuery(T dto) { return getMapper().saveQuery(dto); } @@ -100,6 +126,14 @@ public abstract class AbstractModelDao> extends Abstract return findOneByStatement(query); } + public ListenableFuture findByIdAsync(UUID key) { + log.debug("Get entity by key {}", key); + Select.Where query = select().from(getColumnFamilyName()).where(eq(ModelConstants.ID_PROPERTY, key)); + log.trace("Execute query {}", query); + return findOneByStatementAsync(query); + } + + public ResultSet removeById(UUID key) { Statement delete = QueryBuilder.delete().all().from(getColumnFamilyName()).where(eq(ModelConstants.ID_PROPERTY, key)); log.debug("Remove request: {}", delete.toString()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 7aa35e7c0e..2703cdc23a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao; import com.datastax.driver.core.ResultSet; +import com.google.common.util.concurrent.ListenableFuture; import java.util.List; import java.util.UUID; @@ -26,6 +27,8 @@ public interface Dao { T findById(UUID id); + ListenableFuture findByIdAsync(UUID id); + T save(T t); ResultSet removeById(UUID id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 681188e6be..214d37c330 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.dao.device; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -70,6 +73,14 @@ public class DeviceServiceImpl implements DeviceService { return getData(deviceEntity); } + @Override + public ListenableFuture findDeviceByIdAsync(DeviceId deviceId) { + log.trace("Executing findDeviceById [{}]", deviceId); + validateId(deviceId, "Incorrect deviceId " + deviceId); + ListenableFuture deviceEntity = deviceDao.findByIdAsync(deviceId.getId()); + return Futures.transform(deviceEntity, (Function) input -> getData(input)); + } + @Override public Optional findDeviceByTenantIdAndName(TenantId tenantId, String name) { log.trace("Executing findDeviceByTenantIdAndName [{}][{}]", tenantId, name); diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/exception/UnauthorizedException.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/exception/UnauthorizedException.java new file mode 100644 index 0000000000..7b7d0ec811 --- /dev/null +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/exception/UnauthorizedException.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2017 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.extensions.api.exception; + +/** + * Created by ashvayka on 21.02.17. + */ +public class UnauthorizedException extends Exception { +} diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java index 64d4d535ea..d25c8db60c 100644 --- a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/plugins/PluginContext.java @@ -42,7 +42,7 @@ public interface PluginContext { void reply(PluginToRuleMsg msg); - boolean checkAccess(DeviceId deviceId); + void checkAccess(DeviceId deviceId, PluginCallback callback); Optional getSecurityCtx(); @@ -92,13 +92,9 @@ public interface PluginContext { Attributes API */ - void saveAttributes(DeviceId deviceId, String attributeType, List attributes, PluginCallback callback); + void saveAttributes(TenantId tenantId, DeviceId deviceId, String attributeType, List attributes, PluginCallback callback); - void removeAttributes(DeviceId deviceId, String scope, List attributeKeys, PluginCallback callback); - - void saveAttributesByDevice(TenantId tenantId, DeviceId deviceId, String attributeType, List attributes, PluginCallback callback); - - void removeAttributesByDevice(TenantId tenantId, DeviceId deviceId, String scope, List attributeKeys, PluginCallback callback); + void removeAttributes(TenantId tenantId, DeviceId deviceId, String scope, List attributeKeys, PluginCallback callback); void loadAttribute(DeviceId deviceId, String attributeType, String attributeKey, PluginCallback> callback); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/rpc/handlers/RpcRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/rpc/handlers/RpcRestMsgHandler.java index 4601916fb7..f60f2d815d 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/rpc/handlers/RpcRestMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/rpc/handlers/RpcRestMsgHandler.java @@ -25,6 +25,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.context.request.async.DeferredResult; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.extensions.api.plugins.PluginCallback; import org.thingsboard.server.extensions.api.plugins.PluginContext; import org.thingsboard.server.extensions.api.plugins.handlers.DefaultRestMsgHandler; import org.thingsboard.server.extensions.api.plugins.msg.FromDeviceRpcResponse; @@ -62,27 +63,34 @@ public class RpcRestMsgHandler extends DefaultRestMsgHandler { String method = pathParams[0].toUpperCase(); if (DataConstants.ONEWAY.equals(method) || DataConstants.TWOWAY.equals(method)) { DeviceId deviceId = DeviceId.fromString(pathParams[1]); - if (!ctx.checkAccess(deviceId)) { - msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - return; - } JsonNode rpcRequestBody = jsonMapper.readTree(request.getRequestBody()); RpcRequest cmd = new RpcRequest(rpcRequestBody.get("method").asText(), jsonMapper.writeValueAsString(rpcRequestBody.get("params"))); - if (rpcRequestBody.has("timeout")) { - cmd.setTimeout(rpcRequestBody.get("timeout").asLong()); - } - long timeout = cmd.getTimeout() != null ? cmd.getTimeout() : defaultTimeout; - ToDeviceRpcRequestBody body = new ToDeviceRpcRequestBody(cmd.getMethodName(), cmd.getRequestData()); - ToDeviceRpcRequest rpcRequest = new ToDeviceRpcRequest(UUID.randomUUID(), - ctx.getSecurityCtx().orElseThrow(() -> new IllegalStateException("Security context is empty!")).getTenantId(), - deviceId, - DataConstants.ONEWAY.equals(method), - System.currentTimeMillis() + timeout, - body - ); - rpcManager.process(ctx, new LocalRequestMetaData(rpcRequest, msg.getResponseHolder())); + + ctx.checkAccess(deviceId, new PluginCallback() { + @Override + public void onSuccess(PluginContext ctx, Void value) { + if (rpcRequestBody.has("timeout")) { + cmd.setTimeout(rpcRequestBody.get("timeout").asLong()); + } + long timeout = cmd.getTimeout() != null ? cmd.getTimeout() : defaultTimeout; + ToDeviceRpcRequestBody body = new ToDeviceRpcRequestBody(cmd.getMethodName(), cmd.getRequestData()); + ToDeviceRpcRequest rpcRequest = new ToDeviceRpcRequest(UUID.randomUUID(), + ctx.getSecurityCtx().orElseThrow(() -> new IllegalStateException("Security context is empty!")).getTenantId(), + deviceId, + DataConstants.ONEWAY.equals(method), + System.currentTimeMillis() + timeout, + body + ); + rpcManager.process(ctx, new LocalRequestMetaData(rpcRequest, msg.getResponseHolder())); + } + + @Override + public void onFailure(PluginContext ctx, Exception e) { + msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); + } + }); valid = true; } } diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java index 28d8b7c5ce..2bd17aab87 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java @@ -164,7 +164,7 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { } }); if (attributes.size() > 0) { - ctx.saveAttributes(deviceId, scope, attributes, new PluginCallback() { + ctx.saveAttributes(ctx.getSecurityCtx().orElseThrow(() -> new IllegalArgumentException()).getTenantId(), deviceId, scope, attributes, new PluginCallback() { @Override public void onSuccess(PluginContext ctx, Void value) { msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.OK)); @@ -182,8 +182,8 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { } } } - } catch (IOException e) { - log.debug("Failed to process POST request due to IO exception", e); + } catch (IOException | RuntimeException e) { + log.debug("Failed to process POST request due to exception", e); } msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); } @@ -202,7 +202,7 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { String keysParam = request.getParameter("keys"); if (!StringUtils.isEmpty(keysParam)) { String[] keys = keysParam.split(","); - ctx.removeAttributes(deviceId, scope, Arrays.asList(keys), new PluginCallback() { + ctx.removeAttributes(ctx.getSecurityCtx().orElseThrow(() -> new IllegalArgumentException()).getTenantId(), deviceId, scope, Arrays.asList(keys), new PluginCallback() { @Override public void onSuccess(PluginContext ctx, Void value) { msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.OK)); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java index d9bfba073e..1ce797fe4b 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRuleMsgHandler.java @@ -120,7 +120,7 @@ public class TelemetryRuleMsgHandler extends DefaultRuleMsgHandler { @Override public void handleUpdateAttributesRequest(PluginContext ctx, TenantId tenantId, RuleId ruleId, UpdateAttributesRequestRuleToPluginMsg msg) { UpdateAttributesRequest request = msg.getPayload(); - ctx.saveAttributes(msg.getDeviceId(), DataConstants.CLIENT_SCOPE, request.getAttributes().stream().collect(Collectors.toList()), + ctx.saveAttributes(msg.getTenantId(), msg.getDeviceId(), DataConstants.CLIENT_SCOPE, request.getAttributes().stream().collect(Collectors.toList()), new PluginCallback() { @Override public void onSuccess(PluginContext ctx, Void value) { diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index dd30f99e9c..fbfacd3983 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -21,6 +21,7 @@ import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.kv.*; +import org.thingsboard.server.extensions.api.exception.UnauthorizedException; import org.thingsboard.server.extensions.api.plugins.PluginCallback; import org.thingsboard.server.extensions.api.plugins.PluginContext; import org.thingsboard.server.extensions.api.plugins.handlers.DefaultWebsocketMsgHandler; @@ -122,8 +123,14 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { log.error("Failed to fetch attributes!", e); - SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, - "Failed to fetch attributes!"); + SubscriptionUpdate update; + if (UnauthorizedException.class.isInstance(e)) { + update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, + SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); + } else { + update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch attributes!"); + } sendWsMsg(ctx, sessionRef, update); } }; @@ -207,8 +214,14 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { - SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, - "Failed to fetch data!"); + SubscriptionUpdate update; + if (UnauthorizedException.class.isInstance(e)) { + update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, + SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); + } else { + update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch data!"); + } sendWsMsg(ctx, sessionRef, update); } }); @@ -263,12 +276,6 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { return; } DeviceId deviceId = DeviceId.fromString(cmd.getDeviceId()); - if (!ctx.checkAccess(deviceId)) { - SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, - SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); - sendWsMsg(ctx, sessionRef, update); - return; - } List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); List queries = keys.stream().map(key -> new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), cmd.getLimit(), Aggregation.valueOf(cmd.getAgg()))).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { @@ -279,8 +286,15 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { @Override public void onFailure(PluginContext ctx, Exception e) { - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, - "Failed to fetch data!")); + SubscriptionUpdate update; + if (UnauthorizedException.class.isInstance(e)) { + update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, + SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); + } else { + update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + "Failed to fetch data!"); + } + sendWsMsg(ctx, sessionRef, update); } }); } @@ -313,13 +327,6 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { sendWsMsg(ctx, sessionRef, update); return false; } - DeviceId deviceId = DeviceId.fromString(cmd.getDeviceId()); - if (!ctx.checkAccess(deviceId)) { - SubscriptionUpdate update = new SubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, - SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); - sendWsMsg(ctx, sessionRef, update); - return false; - } return true; } From e3c823ef37d4971e5964854c2e080e663cb156c5 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 22 Feb 2017 13:53:56 +0200 Subject: [PATCH 06/14] Implementation and test fixes --- .../device/DeviceActorMessageProcessor.java | 29 +++++++++++++---- .../plugin/PluginProcessingContext.java | 5 ++- .../actors/DefaultActorServiceTest.java | 5 ++- .../dao/attributes/BaseAttributesDao.java | 2 +- .../dao/timeseries/BaseTimeseriesDao.java | 4 +-- .../api/device/DeviceAttributes.java | 26 +++++++++++++++ .../DeviceAttributesEventNotificationMsg.java | 32 +++++++++++-------- .../handlers/TelemetryRestMsgHandler.java | 2 +- .../TelemetryWebsocketMsgHandler.java | 16 +++++++--- .../mqtt/session/GatewaySessionCtx.java | 4 ++- 10 files changed, 93 insertions(+), 32 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index f9c4e6f5c0..da2c62016a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -58,6 +58,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import java.util.function.Predicate; @@ -85,14 +86,24 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso this.attributeSubscriptions = new HashMap<>(); this.rpcSubscriptions = new HashMap<>(); this.rpcPendingMap = new HashMap<>(); - refreshAttributes(); + initAttributes(); } - private void refreshAttributes() { + private void initAttributes() { this.deviceAttributes = new DeviceAttributes(fetchAttributes(DataConstants.CLIENT_SCOPE), fetchAttributes(DataConstants.SERVER_SCOPE), fetchAttributes(DataConstants.SHARED_SCOPE)); } + private void refreshAttributes(DeviceAttributesEventNotificationMsg msg) { + if (this.deviceAttributes != null) { + if (msg.isDeleted()) { + msg.getDeletedKeys().forEach(key -> deviceAttributes.remove(key)); + } else { + deviceAttributes.update(msg.getScope(), msg.getValues()); + } + } + } + void processRpcRequest(ActorContext context, ToDeviceRpcRequestPluginMsg msg) { ToDeviceRpcRequest request = msg.getMsg(); ToDeviceRpcRequestBody body = request.getBody(); @@ -196,8 +207,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso void processAttributesUpdate(ActorContext context, DeviceAttributesEventNotificationMsg msg) { //TODO: improve this procedure to fetch only changed attributes and support attributes deletion - refreshAttributes(); - Set keys = msg.getKeys(); + refreshAttributes(msg); + Set keys = msg.getDeletedKeys(); if (attributeSubscriptions.size() > 0) { ToDeviceMsg notification = null; if (msg.isDeleted()) { @@ -359,8 +370,14 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private List fetchAttributes(String attributeType) { - return systemContext.getAttributesService().findAll(this.deviceId, attributeType); + private List fetchAttributes(String scope) { + try { + //TODO: replace this with async operation. Happens only during actor creation, but is still criticla for performance, + return systemContext.getAttributesService().findAll(this.deviceId, scope).get(); + } catch (InterruptedException | ExecutionException e) { + logger.warning("[{}] Failed to fetch attributes for scope: {}", deviceId, scope); + throw new RuntimeException(e); + } } public void processCredentialsUpdate(ActorContext context, DeviceCredentialsUpdateNotificationMsg msg) { diff --git a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java index b102226921..bea51dbedb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/plugin/PluginProcessingContext.java @@ -302,9 +302,8 @@ public final class PluginProcessingContext implements PluginContext { @Override public void getDevice(DeviceId deviceId, PluginCallback callback) { - //TODO: add caching here with async api. - Device device = pluginCtx.deviceService.findDeviceById(deviceId); - pluginCtx.self().tell(PluginCallbackMessage.onSuccess(callback, device), ActorRef.noSender()); + ListenableFuture deviceFuture = pluginCtx.deviceService.findDeviceByIdAsync(deviceId); + Futures.addCallback(deviceFuture, getCallback(callback, v -> v)); } @Override diff --git a/application/src/test/java/org/thingsboard/server/actors/DefaultActorServiceTest.java b/application/src/test/java/org/thingsboard/server/actors/DefaultActorServiceTest.java index f6cb4e31da..2940a62a93 100644 --- a/application/src/test/java/org/thingsboard/server/actors/DefaultActorServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/DefaultActorServiceTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.when; import java.util.*; +import com.google.common.util.concurrent.Futures; import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.common.data.id.*; import org.thingsboard.server.common.data.kv.TsKvEntry; @@ -226,7 +227,9 @@ public class DefaultActorServiceTest { when(pluginMock.getConfiguration()).thenReturn(pluginAdditionalInfo); when(pluginMock.getClazz()).thenReturn(TelemetryStoragePlugin.class.getName()); - when(attributesService.findAll(deviceId, DataConstants.CLIENT_SCOPE)).thenReturn(Collections.emptyList()); + when(attributesService.findAll(deviceId, DataConstants.CLIENT_SCOPE)).thenReturn(Futures.immediateFuture(Collections.emptyList())); + when(attributesService.findAll(deviceId, DataConstants.SHARED_SCOPE)).thenReturn(Futures.immediateFuture(Collections.emptyList())); + when(attributesService.findAll(deviceId, DataConstants.SERVER_SCOPE)).thenReturn(Futures.immediateFuture(Collections.emptyList())); initActorSystem(); Thread.sleep(1000); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java index 262d15d1a3..fd50f4d2ef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesDao.java @@ -68,7 +68,7 @@ public class BaseAttributesDao extends AbstractAsyncDao implements AttributesDao .and(eq(ATTRIBUTE_KEY_COLUMN, attributeKey)); log.trace("Generated query [{}] for entityId {} and key {}", select, entityId, attributeKey); return Futures.transform(executeAsyncRead(select), (Function>) input -> - Optional.of(convertResultToAttributesKvEntry(attributeKey, input.one())) + Optional.ofNullable(convertResultToAttributesKvEntry(attributeKey, input.one())) , readResultsProcessingExecutor); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 42fede47f8..19a260f9e0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -133,8 +133,8 @@ public class BaseTimeseriesDao extends AbstractAsyncDao implements TimeseriesDao } private ListenableFuture> findAllAsyncWithLimit(String entityType, UUID entityId, TsKvQuery query) { - long minPartition = query.getStartTs(); - long maxPartition = query.getEndTs(); + long minPartition = toPartitionTs(query.getStartTs()); + long maxPartition = toPartitionTs(query.getEndTs()); ResultSetFuture partitionsFuture = fetchPartitions(entityType, entityId, query.getKey(), minPartition, maxPartition); diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributes.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributes.java index 8a43e32b5f..8628d0c16c 100644 --- a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributes.java +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributes.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.extensions.api.device; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import java.util.*; @@ -65,4 +67,28 @@ public class DeviceAttributes { public Optional getServerPublicAttribute(String attribute) { return Optional.ofNullable(serverPublicAttributesMap.get(attribute)); } + + public void remove(AttributeKey key) { + Map map = getMapByScope(key.getScope()); + if (map != null) { + map.remove(key); + } + } + + public void update(String scope, List values) { + Map map = getMapByScope(scope); + values.forEach(v -> map.put(v.getKey(), v)); + } + + private Map getMapByScope(String scope) { + Map map = null; + if (scope.equalsIgnoreCase(DataConstants.CLIENT_SCOPE)) { + map = clientSideAttributesMap; + } else if (scope.equalsIgnoreCase(DataConstants.SHARED_SCOPE)) { + map = serverPublicAttributesMap; + } else if (scope.equalsIgnoreCase(DataConstants.SERVER_SCOPE)) { + map = serverPrivateAttributesMap; + } + return map; + } } diff --git a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributesEventNotificationMsg.java b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributesEventNotificationMsg.java index 4ff72ee045..f25f4f82a3 100644 --- a/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributesEventNotificationMsg.java +++ b/extensions-api/src/main/java/org/thingsboard/server/extensions/api/device/DeviceAttributesEventNotificationMsg.java @@ -15,37 +15,43 @@ */ package org.thingsboard.server.extensions.api.device; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKey; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import java.util.List; import java.util.Set; /** * @author Andrew Shvayka */ @ToString +@AllArgsConstructor public class DeviceAttributesEventNotificationMsg implements ToDeviceActorNotificationMsg { - @Getter private final TenantId tenantId; - @Getter private final DeviceId deviceId; - @Getter private final Set keys; - @Getter private final boolean deleted; + @Getter + private final TenantId tenantId; + @Getter + private final DeviceId deviceId; + @Getter + private final Set deletedKeys; + @Getter + private final String scope; + @Getter + private final List values; + @Getter + private final boolean deleted; - public static DeviceAttributesEventNotificationMsg onUpdate(TenantId tenantId, DeviceId deviceId, Set keys) { - return new DeviceAttributesEventNotificationMsg(tenantId, deviceId, keys, false); + public static DeviceAttributesEventNotificationMsg onUpdate(TenantId tenantId, DeviceId deviceId, String scope, List values) { + return new DeviceAttributesEventNotificationMsg(tenantId, deviceId, null, scope, values, false); } public static DeviceAttributesEventNotificationMsg onDelete(TenantId tenantId, DeviceId deviceId, Set keys) { - return new DeviceAttributesEventNotificationMsg(tenantId, deviceId, keys, true); + return new DeviceAttributesEventNotificationMsg(tenantId, deviceId, keys, null, null, true); } - private DeviceAttributesEventNotificationMsg(TenantId tenantId, DeviceId deviceId, Set keys, boolean deleted) { - this.tenantId = tenantId; - this.deviceId = deviceId; - this.keys = keys; - this.deleted = deleted; - } } diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java index 2bd17aab87..78fa4ad873 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java @@ -93,7 +93,7 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { Aggregation agg = Aggregation.valueOf(request.getParameter("agg", Aggregation.NONE.name())); List keys = Arrays.asList(keysStr.split(",")); - List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs.get(), endTs.get(), limit.get(), agg)).collect(Collectors.toList()); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs.get(), endTs.get(), limit.orElse(TelemetryWebsocketMsgHandler.DEFAULT_LIMIT), agg)).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { @Override public void onSuccess(PluginContext ctx, List data) { diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index fbfacd3983..4bc7ae0df5 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -48,6 +48,8 @@ import java.util.stream.Collectors; public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { private static final int UNKNOWN_SUBSCRIPTION_ID = 0; + public static final int DEFAULT_LIMIT = 100; + public static final Aggregation DEFAULT_AGGREGATION = Aggregation.NONE; private final SubscriptionManager subscriptionManager; @@ -187,13 +189,11 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { if (keysOptional.isPresent()) { long startTs; if (cmd.getTimeWindow() > 0) { - List data = new ArrayList<>(); List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); log.debug("[{}] fetching timeseries data for last {} ms for keys: ({}) for device : {}", sessionId, cmd.getTimeWindow(), cmd.getKeys(), cmd.getDeviceId()); long endTs = System.currentTimeMillis(); startTs = endTs - cmd.getTimeWindow(); - - List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs, endTs, cmd.getLimit(), Aggregation.valueOf(cmd.getAgg()))).collect(Collectors.toList()); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs, endTs, getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, getSubscriptionCallback(sessionRef, cmd, sessionId, deviceId, startTs, keys)); } else { List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); @@ -277,7 +277,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { } DeviceId deviceId = DeviceId.fromString(cmd.getDeviceId()); List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); - List queries = keys.stream().map(key -> new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), cmd.getLimit(), Aggregation.valueOf(cmd.getAgg()))).collect(Collectors.toList()); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { @Override public void onSuccess(PluginContext ctx, List data) { @@ -299,6 +299,14 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { }); } + private static Aggregation getAggregation(String agg) { + return StringUtils.isEmpty(agg) ? DEFAULT_AGGREGATION : Aggregation.valueOf(agg); + } + + private int getLimit(int limit) { + return limit == 0 ? DEFAULT_LIMIT : limit; + } + private boolean validateSessionMetadata(PluginContext ctx, PluginWebsocketSessionRef sessionRef, SubscriptionCmd cmd, String sessionId) { WsSessionMetaData sessionMD = wsSessionsMap.get(sessionId); if (sessionMD == null) { diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java index 2badd3ab79..854ad8f1e9 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java @@ -91,7 +91,9 @@ public class GatewaySessionCtx { public void onDeviceDisconnect(MqttPublishMessage msg) throws AdaptorException { String deviceName = checkDeviceName(getDeviceName(msg)); GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); - deviceSessionCtx.setClosed(true); + if (deviceSessionCtx != null) { + deviceSessionCtx.setClosed(true); + } ack(msg); } From abee0ea2d9aa6b6a989c882b76a984c1dc02eef8 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 22 Feb 2017 14:08:51 +0200 Subject: [PATCH 07/14] Minor code cleanup --- .../actors/device/DeviceActorMessageProcessor.java | 11 ++++------- .../server/dao/timeseries/SimpleListenableFuture.java | 4 ---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index da2c62016a..d4c42d82b7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -95,12 +95,10 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } private void refreshAttributes(DeviceAttributesEventNotificationMsg msg) { - if (this.deviceAttributes != null) { - if (msg.isDeleted()) { - msg.getDeletedKeys().forEach(key -> deviceAttributes.remove(key)); - } else { - deviceAttributes.update(msg.getScope(), msg.getValues()); - } + if (msg.isDeleted()) { + msg.getDeletedKeys().forEach(key -> deviceAttributes.remove(key)); + } else { + deviceAttributes.update(msg.getScope(), msg.getValues()); } } @@ -206,7 +204,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } void processAttributesUpdate(ActorContext context, DeviceAttributesEventNotificationMsg msg) { - //TODO: improve this procedure to fetch only changed attributes and support attributes deletion refreshAttributes(msg); Set keys = msg.getDeletedKeys(); if (attributeSubscriptions.size() > 0) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java index e10a40de5f..3f3e0314c3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java @@ -22,10 +22,6 @@ import com.google.common.util.concurrent.AbstractFuture; */ public class SimpleListenableFuture extends AbstractFuture { - public SimpleListenableFuture() { - - } - public boolean set(V value) { return super.set(value); } From e4963de151f660b07f3fcc5296e5191e7926a67e Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 24 Feb 2017 13:04:52 +0200 Subject: [PATCH 08/14] UI: Add timeseries aggregation support --- .../dao/timeseries/BaseTimeseriesDao.java | 5 +- .../TelemetryWebsocketMsgHandler.java | 2 +- .../telemetry/sub/SubscriptionUpdate.java | 12 +- ui/src/app/api/data-aggregator.js | 238 ++++++++++++++++++ ui/src/app/api/datasource.service.js | 116 ++++++--- ui/src/app/api/device.service.js | 4 +- ui/src/app/api/telemetry-websocket.service.js | 4 +- ui/src/app/common/types.constant.js | 26 ++ ui/src/app/components/dashboard.tpl.html | 2 +- .../components/timewindow-panel.controller.js | 4 +- .../app/components/timewindow-panel.tpl.html | 20 +- ui/src/app/components/timewindow.directive.js | 54 +++- ui/src/app/components/timewindow.scss | 9 +- ui/src/app/components/widget-config.tpl.html | 2 +- ui/src/app/components/widget.controller.js | 55 ++-- ui/src/app/locale/locale.constant.js | 11 + 16 files changed, 480 insertions(+), 84 deletions(-) create mode 100644 ui/src/app/api/data-aggregator.js diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 19a260f9e0..10651cbad0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -53,8 +53,9 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.select; @Slf4j public class BaseTimeseriesDao extends AbstractAsyncDao implements TimeseriesDao { - @Value("${cassandra.query.min_aggregation_step_ms}") - private int minAggregationStepMs; + //@Value("${cassandra.query.min_aggregation_step_ms}") + //TODO: + private int minAggregationStepMs = 1000; @Value("${cassandra.query.ts_key_value_partitioning}") private String partitioning; diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index 4bc7ae0df5..8385bf1893 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -234,7 +234,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { return new PluginCallback>() { @Override public void onSuccess(PluginContext ctx, List data) { - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), startTs, data)); Map subState = new HashMap<>(keys.size()); keys.forEach(key -> subState.put(key, startTs)); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java index 4d8cf5310e..8a9e7b2b24 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java @@ -26,10 +26,16 @@ public class SubscriptionUpdate { private int errorCode; private String errorMsg; private Map> data; + private long serverStartTs; public SubscriptionUpdate(int subscriptionId, List data) { + this(subscriptionId, 0L, data); + } + + public SubscriptionUpdate(int subscriptionId, long serverStartTs, List data) { super(); this.subscriptionId = subscriptionId; + this.serverStartTs = serverStartTs; this.data = new TreeMap<>(); for (TsKvEntry tsEntry : data) { List values = this.data.get(tsEntry.getKey()); @@ -89,9 +95,13 @@ public class SubscriptionUpdate { return errorMsg; } + public long getServerStartTs() { + return serverStartTs; + } + @Override public String toString() { return "SubscriptionUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", data=" - + data + "]"; + + data + ", serverStartTs=" + serverStartTs+ "]"; } } diff --git a/ui/src/app/api/data-aggregator.js b/ui/src/app/api/data-aggregator.js new file mode 100644 index 0000000000..314ba64ba7 --- /dev/null +++ b/ui/src/app/api/data-aggregator.js @@ -0,0 +1,238 @@ +/* + * Copyright © 2016-2017 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. + */ + +export default class DataAggregator { + + constructor(onDataCb, limit, aggregationType, timeWindow, types, $timeout, $filter) { + this.onDataCb = onDataCb; + this.aggregationType = aggregationType; + this.types = types; + this.$timeout = $timeout; + this.$filter = $filter; + this.dataReceived = false; + this.noAggregation = aggregationType === types.aggregation.none.value; + var interval = Math.floor(timeWindow / limit); + if (!this.noAggregation) { + this.interval = Math.max(interval, 1000); + this.limit = Math.ceil(interval/this.interval * limit); + this.timeWindow = this.interval * this.limit; + } else { + this.limit = limit; + this.timeWindow = interval * this.limit; + this.interval = 1000; + } + this.aggregationTimeout = this.interval; + switch (aggregationType) { + case types.aggregation.min.value: + this.aggFunction = min; + break; + case types.aggregation.max.value: + this.aggFunction = max + break; + case types.aggregation.avg.value: + this.aggFunction = avg; + break; + case types.aggregation.sum.value: + this.aggFunction = sum; + break; + case types.aggregation.count.value: + this.aggFunction = count; + break; + case types.aggregation.none.value: + this.aggFunction = none; + break; + default: + this.aggFunction = avg; + } + } + + onData(data) { + if (!this.dataReceived) { + this.elapsed = 0; + this.dataReceived = true; + this.startTs = data.serverStartTs; + this.endTs = this.startTs + this.timeWindow; + this.aggregationMap = processAggregatedData(data.data, this.aggregationType === this.types.aggregation.count.value, this.noAggregation); + this.onInterval(currentTime()); + } else { + updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, + this.noAggregation, this.aggFunction, data.data, this.interval, this.startTs); + } + } + + onInterval(startedTime) { + var now = currentTime(); + this.elapsed += now - startedTime; + if (this.intervalTimeoutHandle) { + this.$timeout.cancel(this.intervalTimeoutHandle); + this.intervalTimeoutHandle = null; + } + var delta = Math.floor(this.elapsed / this.interval); + if (delta || !this.data) { + this.startTs += delta * this.interval; + this.endTs += delta * this.interval; + this.data = toData(this.aggregationMap, this.startTs, this.endTs, this.$filter, this.limit); + this.elapsed = this.elapsed - delta * this.interval; + } + if (this.onDataCb) { + this.onDataCb(this.data, this.startTs, this.endTs); + } + + var self = this; + this.intervalTimeoutHandle = this.$timeout(function() { + self.onInterval(now); + }, this.aggregationTimeout, false); + } + + reset() { + this.destroy(); + this.dataReceived = false; + } + + destroy() { + if (this.intervalTimeoutHandle) { + this.$timeout.cancel(this.intervalTimeoutHandle); + this.intervalTimeoutHandle = null; + } + this.aggregationMap = null; + } + +} + +/* eslint-disable */ +function currentTime() { + return window.performance && window.performance.now ? + window.performance.now() : Date.now(); +} +/* eslint-enable */ + +function processAggregatedData(data, isCount, noAggregation) { + var aggregationMap = {}; + for (var key in data) { + var aggKeyData = aggregationMap[key]; + if (!aggKeyData) { + aggKeyData = {}; + aggregationMap[key] = aggKeyData; + } + var keyData = data[key]; + for (var i in keyData) { + var kvPair = keyData[i]; + var timestamp = kvPair[0]; + var value = convertValue(kvPair[1], noAggregation); + var aggKey = timestamp; + var aggData = { + count: isCount ? value : 1, + sum: value, + aggValue: value + } + aggKeyData[aggKey] = aggData; + } + } + return aggregationMap; +} + +function updateAggregatedData(aggregationMap, isCount, noAggregation, aggFunction, data, interval, startTs) { + for (var key in data) { + var aggKeyData = aggregationMap[key]; + if (!aggKeyData) { + aggKeyData = {}; + aggregationMap[key] = aggKeyData; + } + var keyData = data[key]; + for (var i in keyData) { + var kvPair = keyData[i]; + var timestamp = kvPair[0]; + var value = convertValue(kvPair[1], noAggregation); + var aggTimestamp = noAggregation ? timestamp : (startTs + Math.floor((timestamp - startTs) / interval) * interval + interval/2); + var aggData = aggKeyData[aggTimestamp]; + if (!aggData) { + aggData = { + count: 1, + sum: value, + aggValue: isCount ? 1 : value + } + aggKeyData[aggTimestamp] = aggData; + } else { + aggFunction(aggData, value); + } + } + } +} + +function toData(aggregationMap, startTs, endTs, $filter, limit) { + var data = {}; + for (var key in aggregationMap) { + if (!data[key]) { + data[key] = []; + } + var aggKeyData = aggregationMap[key]; + var keyData = data[key]; + for (var aggTimestamp in aggKeyData) { + if (aggTimestamp <= startTs) { + delete aggKeyData[aggTimestamp]; + } else if (aggTimestamp <= endTs) { + var aggData = aggKeyData[aggTimestamp]; + var kvPair = [aggTimestamp, aggData.aggValue]; + keyData.push(kvPair); + } + } + keyData = $filter('orderBy')(keyData, '+this[0]'); + if (keyData.length > limit) { + keyData = keyData.slice(keyData.length - limit); + } + data[key] = keyData; + } + return data; +} + +function convertValue(value, noAggregation) { + if (!noAggregation || value && isNumeric(value)) { + return Number(value); + } else { + return value; + } +} + +function isNumeric(value) { + return (value - parseFloat( value ) + 1) >= 0; +} + +function avg(aggData, value) { + aggData.count++; + aggData.sum += value; + aggData.aggValue = aggData.sum / aggData.count; +} + +function min(aggData, value) { + aggData.aggValue = Math.min(aggData.aggValue, value); +} + +function max(aggData, value) { + aggData.aggValue = Math.max(aggData.aggValue, value); +} + +function sum(aggData, value) { + aggData.aggValue = aggData.aggValue + value; +} + +function count(aggData) { + aggData.count++; + aggData.aggValue = aggData.count; +} + +function none(aggData, value) { + aggData.aggValue = value; +} diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js index 565f5117f3..acfe1249d2 100644 --- a/ui/src/app/api/datasource.service.js +++ b/ui/src/app/api/datasource.service.js @@ -17,13 +17,14 @@ import thingsboardApiDevice from './device.service'; import thingsboardApiTelemetryWebsocket from './telemetry-websocket.service'; import thingsboardTypes from '../common/types.constant'; import thingsboardUtils from '../common/utils.service'; +import DataAggregator from './data-aggregator'; export default angular.module('thingsboard.api.datasource', [thingsboardApiDevice, thingsboardApiTelemetryWebsocket, thingsboardTypes, thingsboardUtils]) .factory('datasourceService', DatasourceService) .name; /*@ngInject*/ -function DatasourceService($timeout, $log, telemetryWebsocketService, types, utils) { +function DatasourceService($timeout, $filter, $log, telemetryWebsocketService, types, utils) { var subscriptions = {}; @@ -73,7 +74,7 @@ function DatasourceService($timeout, $log, telemetryWebsocketService, types, uti subscription = subscriptions[listener.datasourceSubscriptionKey]; subscription.syncListener(listener); } else { - subscription = new DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $log, types, utils); + subscription = new DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $filter, $log, types, utils); subscriptions[listener.datasourceSubscriptionKey] = subscription; subscription.start(); } @@ -96,7 +97,7 @@ function DatasourceService($timeout, $log, telemetryWebsocketService, types, uti } -function DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $log, types, utils) { +function DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $filter, $log, types, utils) { var listeners = []; var datasourceType = datasourceSubscription.datasourceType; @@ -134,7 +135,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (!dataKey.func) { dataKey.func = new Function("time", "prevValue", dataKey.funcBody); } - datasourceData[key] = []; + datasourceData[key] = { + data: [] + }; dataKeys[key] = dataKey; } else if (datasourceType === types.datasourceType.device) { key = dataKey.name + '_' + dataKey.type; @@ -147,7 +150,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic dataKeys[key] = dataKeysList; } var index = dataKeysList.push(dataKey) - 1; - datasourceData[key + '_' + index] = []; + datasourceData[key + '_' + index] = { + data: [] + }; } dataKey.key = key; } @@ -248,14 +253,18 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic deviceId: datasourceSubscription.deviceId, keys: tsKeys, startTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.startTimeMs, - endTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs + endTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs, + limit: datasourceSubscription.subscriptionTimewindow.aggregation.limit, + agg: datasourceSubscription.subscriptionTimewindow.aggregation.type }; subscriber = { historyCommand: historyCommand, type: types.dataKeyType.timeseries, onData: function (data) { - onData(data, types.dataKeyType.timeseries); + if (data.data) { + onData(data.data, types.dataKeyType.timeseries); + } }, onReconnected: function() { onReconnected(); @@ -272,20 +281,46 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic keys: tsKeys }; - if (datasourceSubscription.type === types.widgetType.timeseries.value) { - subscriptionCommand.timeWindow = datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; - } - subscriber = { subscriptionCommand: subscriptionCommand, - type: types.dataKeyType.timeseries, - onData: function (data) { - onData(data, types.dataKeyType.timeseries); - }, - onReconnected: function() { + type: types.dataKeyType.timeseries + }; + + if (datasourceSubscription.type === types.widgetType.timeseries.value) { + subscriptionCommand.timeWindow = datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; + subscriptionCommand.limit = datasourceSubscription.subscriptionTimewindow.aggregation.limit; + subscriptionCommand.agg = datasourceSubscription.subscriptionTimewindow.aggregation.type; + var dataAggregator = new DataAggregator( + function(data, startTs, endTs) { + onData(data, types.dataKeyType.timeseries, startTs, endTs); + }, + subscriptionCommand.limit, + subscriptionCommand.agg, + subscriptionCommand.timeWindow, + types, + $timeout, + $filter + ); + subscriber.onData = function(data) { + dataAggregator.onData(data); + } + subscriber.onReconnected = function() { + dataAggregator.reset(); onReconnected(); } - }; + subscriber.onDestroy = function() { + dataAggregator.destroy(); + } + } else { + subscriber.onReconnected = function() { + onReconnected(); + } + subscriber.onData = function(data) { + if (data.data) { + onData(data.data, types.dataKeyType.timeseries); + } + } + } telemetryWebsocketService.subscribe(subscriber); subscribers[subscriber.subscriptionCommand.cmdId] = subscriber; @@ -304,7 +339,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic subscriptionCommand: subscriptionCommand, type: types.dataKeyType.attribute, onData: function (data) { - onData(data, types.dataKeyType.attribute); + if (data.data) { + onData(data.data, types.dataKeyType.attribute); + } }, onReconnected: function() { onReconnected(); @@ -332,11 +369,14 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } if (datasourceType === types.datasourceType.device) { for (var cmdId in subscribers) { - telemetryWebsocketService.unsubscribe(subscribers[cmdId]); + var subscriber = subscribers[cmdId]; + telemetryWebsocketService.unsubscribe(subscriber); + if (subscriber.onDestroy) { + subscriber.onDestroy(); + } } subscribers = {}; } - //$log.debug("unsibscribed!"); } function boundToInterval(data, timewindowMs) { @@ -360,7 +400,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic function generateSeries(dataKey, startTime, endTime) { var data = []; var prevSeries; - var datasourceKeyData = datasourceData[dataKey.key]; + var datasourceKeyData = datasourceData[dataKey.key].data; if (datasourceKeyData.length > 0) { prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; } else { @@ -378,10 +418,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic dataKey.lastUpdateTime = data[data.length - 1][0]; } if (realtime) { - datasourceData[dataKey.key] = boundToInterval(datasourceKeyData.concat(data), + datasourceData[dataKey.key].data = boundToInterval(datasourceKeyData.concat(data), datasourceSubscription.subscriptionTimewindow.realtimeWindowMs); } else { - datasourceData[dataKey.key] = data; + datasourceData[dataKey.key].data = data; } for (var i in listeners) { var listener = listeners[i]; @@ -393,7 +433,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic function generateLatest(dataKey) { var prevSeries; - var datasourceKeyData = datasourceData[dataKey.key]; + var datasourceKeyData = datasourceData[dataKey.key].data; if (datasourceKeyData.length > 0) { prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; } else { @@ -404,7 +444,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic series.push(time); var value = dataKey.func(time, prevSeries[1]); series.push(value); - datasourceData[dataKey.key] = [series]; + datasourceData[dataKey.key].data = [series]; for (var i in listeners) { var listener = listeners[i]; listener.dataUpdated(datasourceData[dataKey.key], @@ -453,7 +493,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic for (var i = 0; i < dataKeysList.length; i++) { var dataKey = dataKeysList[i]; var datasourceKey = key + '_' + i; - datasourceData[datasourceKey] = []; + datasourceData[datasourceKey] = { + data: [] + }; for (var l in listeners) { var listener = listeners[l]; listener.dataUpdated(datasourceData[datasourceKey], @@ -477,18 +519,23 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } } - function onData(sourceData, type) { + function onData(sourceData, type, startTs, endTs) { for (var keyName in sourceData) { var keyData = sourceData[keyName]; var key = keyName + '_' + type; var dataKeyList = dataKeys[key]; for (var keyIndex = 0; keyIndex < dataKeyList.length; keyIndex++) { var datasourceKey = key + "_" + keyIndex; - if (datasourceData[datasourceKey]) { + if (datasourceData[datasourceKey].data) { var dataKey = dataKeyList[keyIndex]; var data = []; var prevSeries; - var datasourceKeyData = datasourceData[datasourceKey]; + var datasourceKeyData; + if (realtime) { + datasourceKeyData = []; + } else { + datasourceKeyData = datasourceData[datasourceKey].data; + } if (datasourceKeyData.length > 0) { prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; } else { @@ -519,12 +566,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic data.push(series); } } - if (data.length > 0) { - if (realtime) { - datasourceData[datasourceKey] = boundToInterval(datasourceKeyData.concat(data), datasourceSubscription.subscriptionTimewindow.realtimeWindowMs); - } else { - datasourceData[datasourceKey] = data; - } + if (data.length > 0 || (startTs && endTs)) { + datasourceData[datasourceKey].data = data; + datasourceData[datasourceKey].startTs = startTs; + datasourceData[datasourceKey].endTs = endTs; for (var i2 in listeners) { var listener = listeners[i2]; listener.dataUpdated(datasourceData[datasourceKey], @@ -537,3 +582,4 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } } } + diff --git a/ui/src/app/api/device.service.js b/ui/src/app/api/device.service.js index 46c64da261..168005892b 100644 --- a/ui/src/app/api/device.service.js +++ b/ui/src/app/api/device.service.js @@ -304,7 +304,9 @@ function DeviceService($http, $q, $filter, telemetryWebsocketService, types) { subscriptionCommand: subscriptionCommand, type: type, onData: function (data) { - onSubscriptionData(data, subscriptionId); + if (data.data) { + onSubscriptionData(data.data, subscriptionId); + } } }; deviceAttributesSubscription = { diff --git a/ui/src/app/api/telemetry-websocket.service.js b/ui/src/app/api/telemetry-websocket.service.js index 353c73639b..e50422045e 100644 --- a/ui/src/app/api/telemetry-websocket.service.js +++ b/ui/src/app/api/telemetry-websocket.service.js @@ -131,8 +131,8 @@ function TelemetryWebsocketService($rootScope, $websocket, $timeout, $window, ty var data = angular.fromJson(message.data); if (data.subscriptionId) { var subscriber = subscribers[data.subscriptionId]; - if (subscriber && data.data) { - subscriber.onData(data.data); + if (subscriber && data) { + subscriber.onData(data); } } } diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 531403891f..79eb2c20f7 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -33,6 +33,32 @@ export default angular.module('thingsboard.types', []) id: { nullUid: "13814000-1dd2-11b2-8080-808080808080", }, + aggregation: { + min: { + value: "MIN", + name: "aggregation.min" + }, + max: { + value: "MAX", + name: "aggregation.max" + }, + avg: { + value: "AVG", + name: "aggregation.avg" + }, + sum: { + value: "SUM", + name: "aggregation.sum" + }, + count: { + value: "COUNT", + name: "aggregation.count" + }, + none: { + value: "NONE", + name: "aggregation.none" + } + }, datasourceType: { function: "function", device: "device" diff --git a/ui/src/app/components/dashboard.tpl.html b/ui/src/app/components/dashboard.tpl.html index a2110ccbcc..0e367e9a2f 100644 --- a/ui/src/app/components/dashboard.tpl.html +++ b/ui/src/app/components/dashboard.tpl.html @@ -47,7 +47,7 @@ padding: vm.widgetPadding(widget)}">
{{widget.config.title}} - +
-
+ @@ -52,6 +52,24 @@ + + + + + + {{type.name | translate}} + + + + + aggregation.limit + + + + + + +
diff --git a/ui/src/app/components/timewindow.directive.js b/ui/src/app/components/timewindow.directive.js index d3be6373c9..f06c1198b9 100644 --- a/ui/src/app/components/timewindow.directive.js +++ b/ui/src/app/components/timewindow.directive.js @@ -15,6 +15,7 @@ */ import './timewindow.scss'; +import $ from 'jquery'; import thingsboardTimeinterval from './timeinterval.directive'; import thingsboardDatetimePeriod from './datetime-period.directive'; @@ -34,8 +35,9 @@ export default angular.module('thingsboard.directives.timewindow', [thingsboardT .filter('milliSecondsToTimeString', MillisecondsToTimeString) .name; +/* eslint-disable angular/angularelement */ /*@ngInject*/ -function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $translate) { +function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdMedia, $translate, types) { var linker = function (scope, element, attrs, ngModelCtrl) { @@ -50,12 +52,18 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra * startTimeMs: 0, * endTimeMs: 0 * } + * }, + * aggregation: { + * limit: 200, + * type: types.aggregation.avg.value * } * } */ scope.historyOnly = angular.isDefined(attrs.historyOnly); + scope.aggregation = angular.isDefined(attrs.aggregation); + var translationPending = false; $translate.onReady(function() { @@ -84,9 +92,27 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra } scope.openEditMode = function (event) { - var position = $mdPanel.newPanelPosition() - .relativeTo(element) - .addPanelPosition($mdPanel.xPosition.ALIGN_START, $mdPanel.yPosition.BELOW); + var position; + var isGtSm = $mdMedia('gt-sm'); + if (isGtSm) { + var panelHeight = 375; + var offset = element[0].getBoundingClientRect(); + var bottomY = offset.bottom - $(window).scrollTop(); //eslint-disable-line + var yPosition; + if (bottomY + panelHeight > $( window ).height()) { //eslint-disable-line + yPosition = $mdPanel.yPosition.ABOVE; + } else { + yPosition = $mdPanel.yPosition.BELOW; + } + position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_START, yPosition); + } else { + position = $mdPanel.newPanelPosition() + .absolute() + .top('0%') + .left('0%'); + } var config = { attachTo: angular.element($document[0].body), controller: 'TimewindowPanelController', @@ -94,9 +120,11 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra templateUrl: timewindowPanelTemplate, panelClass: 'tb-timewindow-panel', position: position, + fullscreen: !isGtSm, locals: { 'timewindow': angular.copy(scope.model), 'historyOnly': scope.historyOnly, + 'aggregation': scope.aggregation, 'onTimewindowUpdate': function (timewindow) { scope.model = timewindow; scope.updateView(); @@ -131,7 +159,10 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra }; } } - + value.aggregation = { + limit: model.aggregation.limit, + type: model.aggregation.type + }; ngModelCtrl.$setViewValue(value); scope.updateDisplayValue(); } @@ -173,6 +204,10 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra startTimeMs: currentTime - 24 * 60 * 60 * 1000, // 1 day by default endTimeMs: currentTime } + }, + aggregation: { + limit: 200, + type: types.aggregation.avg.value } }; if (ngModelCtrl.$viewValue) { @@ -192,6 +227,12 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra model.history.fixedTimewindow.endTimeMs = value.history.fixedTimewindow.endTimeMs; } } + if (angular.isDefined(value.aggregation)) { + model.aggregation.limit = value.aggregation.limit || 200; + if (angular.isDefined(value.aggregation.type) && value.aggregation.type.length > 0) { + model.aggregation.type = value.aggregation.type; + } + } } scope.updateDisplayValue(); }; @@ -240,4 +281,5 @@ function MillisecondsToTimeString($translate) { } return timeString; } -} \ No newline at end of file +} +/* eslint-enable angular/angularelement */ \ No newline at end of file diff --git a/ui/src/app/components/timewindow.scss b/ui/src/app/components/timewindow.scss index fc4a991f90..16c89e81dd 100644 --- a/ui/src/app/components/timewindow.scss +++ b/ui/src/app/components/timewindow.scss @@ -13,8 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +.md-panel { + &.tb-timewindow-panel { + position: absolute; + } +} + .tb-timewindow-panel { - position: absolute; + min-height: 375px; background: white; border-radius: 4px; box-shadow: 0 7px 8px -4px rgba(0, 0, 0, 0.2), diff --git a/ui/src/app/components/widget-config.tpl.html b/ui/src/app/components/widget-config.tpl.html index 58242edd1c..23c64fcc5f 100644 --- a/ui/src/app/components/widget-config.tpl.html +++ b/ui/src/app/components/widget-config.tpl.html @@ -91,7 +91,7 @@
widget-config.timewindow - +
diff --git a/ui/src/app/components/widget.controller.js b/ui/src/app/components/widget.controller.js index cb759a6ced..cdcdbfb687 100644 --- a/ui/src/app/components/widget.controller.js +++ b/ui/src/app/components/widget.controller.js @@ -43,9 +43,9 @@ export default function WidgetController($scope, $timeout, $window, $element, $q var originalTimewindow = null; var subscriptionTimewindow = { fixedWindow: null, - realtimeWindowMs: null + realtimeWindowMs: null, + aggregation: null }; - var timer = null; var dataUpdateTimer = null; var dataUpdateCaf = null; @@ -154,10 +154,10 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } } - function updateTimewindow() { + function updateTimewindow(startTs, endTs) { if (subscriptionTimewindow.realtimeWindowMs) { - widgetContext.timeWindow.maxTime = (new Date).getTime(); - widgetContext.timeWindow.minTime = widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs; + widgetContext.timeWindow.maxTime = endTs || (new Date).getTime(); + widgetContext.timeWindow.minTime = startTs || (widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs); } else if (subscriptionTimewindow.fixedWindow) { widgetContext.timeWindow.maxTime = subscriptionTimewindow.fixedWindow.endTimeMs; widgetContext.timeWindow.minTime = subscriptionTimewindow.fixedWindow.startTimeMs; @@ -170,13 +170,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q dataUpdateTimer = null; } if (widgetContext.inited) { - if (widget.type === types.widgetType.timeseries.value) { - if (!widgetContext.tickUpdate && timer) { - $timeout.cancel(timer); - timer = $timeout(onTick, 1500, false); - } - updateTimewindow(); - } if (dataUpdateCaf) { dataUpdateCaf(); dataUpdateCaf = null; @@ -188,7 +181,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q handleWidgetException(e); } }); - widgetContext.tickUpdate = false; } else { widgetContext.dataUpdatePending = true; } @@ -512,17 +504,20 @@ export default function WidgetController($scope, $timeout, $window, $element, $q var update = true; if (widget.type === types.widgetType.latest.value) { var prevData = widgetContext.data[datasourceIndex + dataKeyIndex].data; - if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.length > 0) { + if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.data.length > 0) { var prevValue = prevData[0][1]; - if (prevValue === sourceData[0][1]) { + if (prevValue === sourceData.data[0][1]) { update = false; } } } if (update) { - widgetContext.data[datasourceIndex + dataKeyIndex].data = sourceData; + if (subscriptionTimewindow.realtimeWindowMs) { + updateTimewindow(sourceData.startTs, sourceData.endTs); + } + widgetContext.data[datasourceIndex + dataKeyIndex].data = sourceData.data; if (widgetContext.data.length > 1 && !dataUpdateTimer) { - dataUpdateTimer = $timeout(onDataUpdated, 100, false); + dataUpdateTimer = $timeout(onDataUpdated, 300, false); } else { onDataUpdated(); } @@ -557,10 +552,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q function unsubscribe() { if (widget.type !== types.widgetType.rpc.value) { - if (timer) { - $timeout.cancel(timer); - timer = null; - } if (dataUpdateTimer) { $timeout.cancel(dataUpdateTimer); dataUpdateTimer = null; @@ -573,19 +564,25 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } } - function onTick() { - widgetContext.tickUpdate = true; - onDataUpdated(); - timer = $timeout(onTick, 1000, false); - } - function subscribe() { if (widget.type !== types.widgetType.rpc.value) { var index = 0; subscriptionTimewindow.fixedWindow = null; subscriptionTimewindow.realtimeWindowMs = null; + subscriptionTimewindow.aggregation = { + limit: 200, + type: types.aggregation.avg.value + }; if (widget.type === types.widgetType.timeseries.value && angular.isDefined(widget.config.timewindow)) { + + if (angular.isDefined(widget.config.timewindow.aggregation)) { + subscriptionTimewindow.aggregation = { + limit: widget.config.timewindow.aggregation.limit || 200, + type: widget.config.timewindow.aggregation.type || types.aggregation.avg.value + }; + } + if (angular.isDefined(widget.config.timewindow.realtime)) { subscriptionTimewindow.realtimeWindowMs = widget.config.timewindow.realtime.timewindowMs; } else if (angular.isDefined(widget.config.timewindow.history)) { @@ -635,10 +632,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q datasourceListeners.push(listener); datasourceService.subscribeToDatasource(listener); } - - if (subscriptionTimewindow.realtimeWindowMs) { - timer = $timeout(onTick, 0, false); - } } } diff --git a/ui/src/app/locale/locale.constant.js b/ui/src/app/locale/locale.constant.js index 57b1e2e2fa..525c3079df 100644 --- a/ui/src/app/locale/locale.constant.js +++ b/ui/src/app/locale/locale.constant.js @@ -63,6 +63,17 @@ export default angular.module('thingsboard.locale', []) "import": "Import", "export": "Export" }, + "aggregation": { + "aggregation": "Aggregation", + "function": "Data aggregation function", + "limit": "Max values", + "min": "Min", + "max": "Max", + "avg": "Average", + "sum": "Sum", + "count": "Count", + "none": "None" + }, "admin": { "general": "General", "general-settings": "General Settings", From a0d7e4be05aafbc8c5cc463ed01a92e9037ed59d Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 27 Feb 2017 15:07:43 +0200 Subject: [PATCH 09/14] UI: Add bars widget. Improve tooltips and aggregation. --- .../controller/DashboardController.java | 7 + .../cmd/TimeseriesSubscriptionCmd.java | 1 + .../TelemetryWebsocketMsgHandler.java | 6 +- .../telemetry/sub/SubscriptionUpdate.java | 12 +- ui/src/app/api/dashboard.service.js | 16 + ui/src/app/api/data-aggregator.js | 71 +- ui/src/app/api/datasource.service.js | 185 ++-- ui/src/app/components/dashboard.directive.js | 26 +- ui/src/app/components/dashboard.tpl.html | 2 +- ui/src/app/components/widget.controller.js | 54 +- ui/src/app/dashboard/dashboard.controller.js | 10 +- ui/src/app/dashboard/dashboard.tpl.html | 1 + .../attribute/attribute-table.directive.js | 6 +- .../device/attribute/attribute-table.tpl.html | 3 +- ui/src/app/widget/lib/flot-widget.js | 859 +++++++++++++++--- .../app/widget/widget-library.controller.js | 2 +- 16 files changed, 994 insertions(+), 267 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 77898ceb7a..fc419b7020 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -32,6 +32,13 @@ import org.thingsboard.server.exception.ThingsboardException; @RequestMapping("/api") public class DashboardController extends BaseController { + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/serverTime", method = RequestMethod.GET) + @ResponseBody + public long getServerTime() throws ThingsboardException { + return System.currentTimeMillis(); + } + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/{dashboardId}", method = RequestMethod.GET) @ResponseBody diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java index f4eacf587a..20bd3e2e07 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java @@ -28,6 +28,7 @@ import org.thingsboard.server.extensions.core.plugin.telemetry.sub.SubscriptionT @Data public class TimeseriesSubscriptionCmd extends SubscriptionCmd { + private long startTs; private long timeWindow; private int limit; private String agg; diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index 8385bf1893..51181fda8e 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -191,8 +191,8 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { if (cmd.getTimeWindow() > 0) { List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); log.debug("[{}] fetching timeseries data for last {} ms for keys: ({}) for device : {}", sessionId, cmd.getTimeWindow(), cmd.getKeys(), cmd.getDeviceId()); - long endTs = System.currentTimeMillis(); - startTs = endTs - cmd.getTimeWindow(); + startTs = cmd.getStartTs(); + long endTs = cmd.getStartTs() + cmd.getTimeWindow(); List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs, endTs, getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, getSubscriptionCallback(sessionRef, cmd, sessionId, deviceId, startTs, keys)); } else { @@ -234,7 +234,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { return new PluginCallback>() { @Override public void onSuccess(PluginContext ctx, List data) { - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), startTs, data)); + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); Map subState = new HashMap<>(keys.size()); keys.forEach(key -> subState.put(key, startTs)); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java index 8a9e7b2b24..4d8cf5310e 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/sub/SubscriptionUpdate.java @@ -26,16 +26,10 @@ public class SubscriptionUpdate { private int errorCode; private String errorMsg; private Map> data; - private long serverStartTs; public SubscriptionUpdate(int subscriptionId, List data) { - this(subscriptionId, 0L, data); - } - - public SubscriptionUpdate(int subscriptionId, long serverStartTs, List data) { super(); this.subscriptionId = subscriptionId; - this.serverStartTs = serverStartTs; this.data = new TreeMap<>(); for (TsKvEntry tsEntry : data) { List values = this.data.get(tsEntry.getKey()); @@ -95,13 +89,9 @@ public class SubscriptionUpdate { return errorMsg; } - public long getServerStartTs() { - return serverStartTs; - } - @Override public String toString() { return "SubscriptionUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", data=" - + data + ", serverStartTs=" + serverStartTs+ "]"; + + data + "]"; } } diff --git a/ui/src/app/api/dashboard.service.js b/ui/src/app/api/dashboard.service.js index 9eb7329b17..be450ed477 100644 --- a/ui/src/app/api/dashboard.service.js +++ b/ui/src/app/api/dashboard.service.js @@ -22,6 +22,7 @@ function DashboardService($http, $q) { var service = { assignDashboardToCustomer: assignDashboardToCustomer, getCustomerDashboards: getCustomerDashboards, + getServerTimeDiff: getServerTimeDiff, getDashboard: getDashboard, getTenantDashboards: getTenantDashboards, deleteDashboard: deleteDashboard, @@ -71,6 +72,21 @@ function DashboardService($http, $q) { return deferred.promise; } + function getServerTimeDiff() { + var deferred = $q.defer(); + var url = '/api/dashboard/serverTime'; + var ct1 = Date.now(); + $http.get(url, null).then(function success(response) { + var ct2 = Date.now(); + var st = response.data; + var stDiff = Math.ceil(st - (ct1+ct2)/2); + deferred.resolve(stDiff); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + function getDashboard(dashboardId) { var deferred = $q.defer(); var url = '/api/dashboard/' + dashboardId; diff --git a/ui/src/app/api/data-aggregator.js b/ui/src/app/api/data-aggregator.js index 314ba64ba7..e273a9d513 100644 --- a/ui/src/app/api/data-aggregator.js +++ b/ui/src/app/api/data-aggregator.js @@ -16,31 +16,26 @@ export default class DataAggregator { - constructor(onDataCb, limit, aggregationType, timeWindow, types, $timeout, $filter) { + constructor(onDataCb, tsKeyNames, startTs, limit, aggregationType, timeWindow, interval, types, $timeout, $filter) { this.onDataCb = onDataCb; + this.tsKeyNames = tsKeyNames; + this.startTs = startTs; this.aggregationType = aggregationType; this.types = types; this.$timeout = $timeout; this.$filter = $filter; this.dataReceived = false; this.noAggregation = aggregationType === types.aggregation.none.value; - var interval = Math.floor(timeWindow / limit); - if (!this.noAggregation) { - this.interval = Math.max(interval, 1000); - this.limit = Math.ceil(interval/this.interval * limit); - this.timeWindow = this.interval * this.limit; - } else { - this.limit = limit; - this.timeWindow = interval * this.limit; - this.interval = 1000; - } + this.limit = limit; + this.timeWindow = timeWindow; + this.interval = interval; this.aggregationTimeout = this.interval; switch (aggregationType) { case types.aggregation.min.value: this.aggFunction = min; break; case types.aggregation.max.value: - this.aggFunction = max + this.aggFunction = max; break; case types.aggregation.avg.value: this.aggFunction = avg; @@ -59,42 +54,56 @@ export default class DataAggregator { } } - onData(data) { + onData(data, update, history) { if (!this.dataReceived) { this.elapsed = 0; this.dataReceived = true; - this.startTs = data.serverStartTs; this.endTs = this.startTs + this.timeWindow; - this.aggregationMap = processAggregatedData(data.data, this.aggregationType === this.types.aggregation.count.value, this.noAggregation); - this.onInterval(currentTime()); + if (update) { + this.aggregationMap = {}; + updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, + this.noAggregation, this.aggFunction, data.data, this.interval, this.startTs); + } else { + this.aggregationMap = processAggregatedData(data.data, this.aggregationType === this.types.aggregation.count.value, this.noAggregation); + } + this.onInterval(currentTime(), history); } else { updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, this.noAggregation, this.aggFunction, data.data, this.interval, this.startTs); + if (history) { + this.onInterval(currentTime(), history); + } } } - onInterval(startedTime) { + onInterval(startedTime, history) { var now = currentTime(); this.elapsed += now - startedTime; if (this.intervalTimeoutHandle) { this.$timeout.cancel(this.intervalTimeoutHandle); this.intervalTimeoutHandle = null; } - var delta = Math.floor(this.elapsed / this.interval); - if (delta || !this.data) { - this.startTs += delta * this.interval; - this.endTs += delta * this.interval; - this.data = toData(this.aggregationMap, this.startTs, this.endTs, this.$filter, this.limit); - this.elapsed = this.elapsed - delta * this.interval; + if (!history) { + var delta = Math.floor(this.elapsed / this.interval); + if (delta || !this.data) { + this.startTs += delta * this.interval; + this.endTs += delta * this.interval; + this.data = toData(this.tsKeyNames, this.aggregationMap, this.startTs, this.endTs, this.$filter, this.limit); + this.elapsed = this.elapsed - delta * this.interval; + } + } else { + this.data = toData(this.tsKeyNames, this.aggregationMap, this.startTs, this.endTs, this.$filter, this.limit); } if (this.onDataCb) { this.onDataCb(this.data, this.startTs, this.endTs); } var self = this; - this.intervalTimeoutHandle = this.$timeout(function() { - self.onInterval(now); - }, this.aggregationTimeout, false); + if (!history) { + this.intervalTimeoutHandle = this.$timeout(function() { + self.onInterval(now); + }, this.aggregationTimeout, false); + } } reset() { @@ -172,12 +181,12 @@ function updateAggregatedData(aggregationMap, isCount, noAggregation, aggFunctio } } -function toData(aggregationMap, startTs, endTs, $filter, limit) { +function toData(tsKeyNames, aggregationMap, startTs, endTs, $filter, limit) { var data = {}; + for (var k in tsKeyNames) { + data[tsKeyNames[k]] = []; + } for (var key in aggregationMap) { - if (!data[key]) { - data[key] = []; - } var aggKeyData = aggregationMap[key]; var keyData = data[key]; for (var aggTimestamp in aggKeyData) { @@ -185,7 +194,7 @@ function toData(aggregationMap, startTs, endTs, $filter, limit) { delete aggKeyData[aggTimestamp]; } else if (aggTimestamp <= endTs) { var aggData = aggKeyData[aggTimestamp]; - var kvPair = [aggTimestamp, aggData.aggValue]; + var kvPair = [Number(aggTimestamp), aggData.aggValue]; keyData.push(kvPair); } } diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js index acfe1249d2..b44f85d18d 100644 --- a/ui/src/app/api/datasource.service.js +++ b/ui/src/app/api/datasource.service.js @@ -108,9 +108,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic datasourceSubscription.subscriptionTimewindow.fixedWindow; var realtime = datasourceSubscription.subscriptionTimewindow && datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; - var dataGenFunction = null; var timer; var frequency; + var dataAggregator; var subscription = { addListener: addListener, @@ -131,19 +131,20 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic dataKey.index = i; var key; if (datasourceType === types.datasourceType.function) { - key = utils.objectHashCode(dataKey); if (!dataKey.func) { dataKey.func = new Function("time", "prevValue", dataKey.funcBody); } - datasourceData[key] = { - data: [] - }; - dataKeys[key] = dataKey; - } else if (datasourceType === types.datasourceType.device) { - key = dataKey.name + '_' + dataKey.type; + } else { if (dataKey.postFuncBody && !dataKey.postFunc) { dataKey.postFunc = new Function("time", "value", "prevValue", dataKey.postFuncBody); } + } + if (datasourceType === types.datasourceType.device || datasourceSubscription.type === types.widgetType.timeseries.value) { + if (datasourceType === types.datasourceType.function) { + key = dataKey.name + '_' + dataKey.index + '_' + dataKey.type; + } else { + key = dataKey.name + '_' + dataKey.type; + } var dataKeysList = dataKeys[key]; if (!dataKeysList) { dataKeysList = []; @@ -153,24 +154,19 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic datasourceData[key + '_' + index] = { data: [] }; + } else { + key = utils.objectHashCode(dataKey); + datasourceData[key] = { + data: [] + }; + dataKeys[key] = dataKey; } dataKey.key = key; } if (datasourceType === types.datasourceType.function) { frequency = 1000; if (datasourceSubscription.type === types.widgetType.timeseries.value) { - dataGenFunction = generateSeries; - var window; - if (realtime) { - window = datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; - } else { - window = datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs - - datasourceSubscription.subscriptionTimewindow.fixedWindow.startTimeMs; - } - frequency = window / 1000 * 20; - } else if (datasourceSubscription.type === types.widgetType.latest.value) { - dataGenFunction = generateLatest; - frequency = 1000; + frequency = Math.min(datasourceSubscription.subscriptionTimewindow.aggregation.interval, 5000); } } } @@ -193,14 +189,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic function syncListener(listener) { var key; var dataKey; - if (datasourceType === types.datasourceType.function) { - for (key in dataKeys) { - dataKey = dataKeys[key]; - listener.dataUpdated(datasourceData[key], - listener.datasourceIndex, - dataKey.index); - } - } else if (datasourceType === types.datasourceType.device) { + if (datasourceType === types.datasourceType.device || datasourceSubscription.type === types.widgetType.timeseries.value) { for (key in dataKeys) { var dataKeysList = dataKeys[key]; for (var i = 0; i < dataKeysList.length; i++) { @@ -211,6 +200,13 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic dataKey.index); } } + } else { + for (key in dataKeys) { + dataKey = dataKeys[key]; + listener.dataUpdated(datasourceData[key], + listener.datasourceIndex, + dataKey.index); + } } } @@ -218,7 +214,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (history && !hasListeners()) { return; } - //$log.debug("started!"); + var subsTw = datasourceSubscription.subscriptionTimewindow; + var tsKeyNames = []; + var dataKey; + if (datasourceType === types.datasourceType.device) { //send subscribe command @@ -228,12 +227,13 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic for (var key in dataKeys) { var dataKeysList = dataKeys[key]; - var dataKey = dataKeysList[0]; + dataKey = dataKeysList[0]; if (dataKey.type === types.dataKeyType.timeseries) { if (tsKeys.length > 0) { tsKeys += ','; } tsKeys += dataKey.name; + tsKeyNames.push(dataKey.name); } else if (dataKey.type === types.dataKeyType.attribute) { if (attrKeys.length > 0) { attrKeys += ','; @@ -252,10 +252,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var historyCommand = { deviceId: datasourceSubscription.deviceId, keys: tsKeys, - startTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.startTimeMs, - endTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs, - limit: datasourceSubscription.subscriptionTimewindow.aggregation.limit, - agg: datasourceSubscription.subscriptionTimewindow.aggregation.type + startTs: subsTw.fixedWindow.startTimeMs, + endTs: subsTw.fixedWindow.endTimeMs, + limit: subsTw.aggregation.limit, + agg: subsTw.aggregation.type }; subscriber = { @@ -287,16 +287,20 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic }; if (datasourceSubscription.type === types.widgetType.timeseries.value) { - subscriptionCommand.timeWindow = datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; - subscriptionCommand.limit = datasourceSubscription.subscriptionTimewindow.aggregation.limit; - subscriptionCommand.agg = datasourceSubscription.subscriptionTimewindow.aggregation.type; - var dataAggregator = new DataAggregator( + subscriptionCommand.startTs = subsTw.startTs; + subscriptionCommand.timeWindow = subsTw.aggregation.timeWindow; + subscriptionCommand.limit = subsTw.aggregation.limit; + subscriptionCommand.agg = subsTw.aggregation.type; + dataAggregator = new DataAggregator( function(data, startTs, endTs) { onData(data, types.dataKeyType.timeseries, startTs, endTs); }, - subscriptionCommand.limit, - subscriptionCommand.agg, - subscriptionCommand.timeWindow, + tsKeyNames, + subsTw.startTs, + subsTw.aggregation.limit, + subsTw.aggregation.type, + subsTw.aggregation.timeWindow, + subsTw.aggregation.interval, types, $timeout, $filter @@ -308,9 +312,6 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic dataAggregator.reset(); onReconnected(); } - subscriber.onDestroy = function() { - dataAggregator.destroy(); - } } else { subscriber.onReconnected = function() { onReconnected(); @@ -353,7 +354,30 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } - } else if (dataGenFunction) { + } else if (datasourceType === types.datasourceType.function) { + if (datasourceSubscription.type === types.widgetType.timeseries.value) { + for (key in dataKeys) { + var dataKeyList = dataKeys[key]; + for (var index = 0; index < dataKeyList.length; index++) { + dataKey = dataKeyList[index]; + tsKeyNames.push(dataKey.name+'_'+dataKey.index); + } + } + dataAggregator = new DataAggregator( + function (data, startTs, endTs) { + onData(data, types.dataKeyType.function, startTs, endTs); + }, + tsKeyNames, + subsTw.startTs, + subsTw.aggregation.limit, + subsTw.aggregation.type, + subsTw.aggregation.timeWindow, + subsTw.aggregation.interval, + types, + $timeout, + $filter + ); + } if (history) { onTick(); } else { @@ -377,30 +401,17 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } subscribers = {}; } - } - - function boundToInterval(data, timewindowMs) { - if (data.length > 1) { - var start = data[0][0]; - var end = data[data.length - 1][0]; - var i = 0; - var currentInterval = end - start; - while (currentInterval > timewindowMs && i < data.length - 2) { - i++; - start = data[i][0]; - currentInterval = end - start; - } - if (i > 1) { - data.splice(0, i - 1); - } + if (dataAggregator) { + dataAggregator.destroy(); + dataAggregator = null; } - return data; } - function generateSeries(dataKey, startTime, endTime) { + function generateSeries(dataKey, index, startTime, endTime) { var data = []; var prevSeries; - var datasourceKeyData = datasourceData[dataKey.key].data; + var datasourceDataKey = dataKey.key + '_' + index; + var datasourceKeyData = datasourceData[datasourceDataKey].data; if (datasourceKeyData.length > 0) { prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; } else { @@ -417,18 +428,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (data.length > 0) { dataKey.lastUpdateTime = data[data.length - 1][0]; } - if (realtime) { - datasourceData[dataKey.key].data = boundToInterval(datasourceKeyData.concat(data), - datasourceSubscription.subscriptionTimewindow.realtimeWindowMs); - } else { - datasourceData[dataKey.key].data = data; - } - for (var i in listeners) { - var listener = listeners[i]; - listener.dataUpdated(datasourceData[dataKey.key], - listener.datasourceIndex, - dataKey.index); - } + return data; } function generateLatest(dataKey) { @@ -458,23 +458,32 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (datasourceSubscription.type === types.widgetType.timeseries.value) { var startTime; var endTime; + var generatedData = { + data: { + } + }; for (key in dataKeys) { - var dataKey = dataKeys[key]; - if (!startTime) { - if (realtime) { - endTime = (new Date).getTime(); - if (dataKey.lastUpdateTime) { - startTime = dataKey.lastUpdateTime + frequency; + var dataKeyList = dataKeys[key]; + for (var index = 0; index < dataKeyList.length; index ++) { + var dataKey = dataKeyList[index]; + if (!startTime) { + if (realtime) { + if (dataKey.lastUpdateTime) { + startTime = dataKey.lastUpdateTime + frequency + } else { + startTime = datasourceSubscription.subscriptionTimewindow.startTs; + } + endTime = startTime + datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; } else { - startTime = endTime - datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; + startTime = datasourceSubscription.subscriptionTimewindow.fixedWindow.startTimeMs; + endTime = datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs; } - } else { - startTime = datasourceSubscription.subscriptionTimewindow.fixedWindow.startTimeMs; - endTime = datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs; } + var data = generateSeries(dataKey, index, startTime, endTime); + generatedData.data[dataKey.name+'_'+dataKey.index] = data; } - generateSeries(dataKey, startTime, endTime); } + dataAggregator.onData(generatedData, true, history); } else if (datasourceSubscription.type === types.widgetType.latest.value) { for (key in dataKeys) { generateLatest(dataKeys[key]); @@ -568,8 +577,6 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } if (data.length > 0 || (startTs && endTs)) { datasourceData[datasourceKey].data = data; - datasourceData[datasourceKey].startTs = startTs; - datasourceData[datasourceKey].endTs = endTs; for (var i2 in listeners) { var listener = listeners[i2]; listener.dataUpdated(datasourceData[datasourceKey], diff --git a/ui/src/app/components/dashboard.directive.js b/ui/src/app/components/dashboard.directive.js index e67a444d70..3889339a23 100644 --- a/ui/src/app/components/dashboard.directive.js +++ b/ui/src/app/components/dashboard.directive.js @@ -68,6 +68,7 @@ function Dashboard() { prepareDashboardContextMenu: '&?', prepareWidgetContextMenu: '&?', loadWidgets: '&?', + getStDiff: '&?', onInit: '&?', onInitFailed: '&?', dashboardStyle: '=?' @@ -94,6 +95,8 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $ vm.gridster = null; + vm.stDiff = 0; + vm.isMobileDisabled = angular.isDefined(vm.isMobileDisabled) ? vm.isMobileDisabled : false; vm.dashboardLoading = true; @@ -302,7 +305,28 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $ }); }); - loadDashboard(); + loadStDiff(); + + function loadStDiff() { + if (vm.getStDiff) { + var promise = vm.getStDiff(); + if (promise) { + promise.then(function (stDiff) { + vm.stDiff = stDiff; + loadDashboard(); + }, function () { + vm.stDiff = 0; + loadDashboard(); + }); + } else { + vm.stDiff = 0; + loadDashboard(); + } + } else { + vm.stDiff = 0; + loadDashboard(); + } + } function loadDashboard() { resetWidgetClick(); diff --git a/ui/src/app/components/dashboard.tpl.html b/ui/src/app/components/dashboard.tpl.html index 0e367e9a2f..dc46e35472 100644 --- a/ui/src/app/components/dashboard.tpl.html +++ b/ui/src/app/components/dashboard.tpl.html @@ -93,7 +93,7 @@
+ locals="{ visibleRect: vm.visibleRect, widget: widget, deviceAliasList: vm.deviceAliasList, isEdit: vm.isEdit, stDiff: vm.stDiff }">
diff --git a/ui/src/app/components/widget.controller.js b/ui/src/app/components/widget.controller.js index cdcdbfb687..f7e9498f7e 100644 --- a/ui/src/app/components/widget.controller.js +++ b/ui/src/app/components/widget.controller.js @@ -20,7 +20,7 @@ import 'javascript-detect-element-resize/detect-element-resize'; /*@ngInject*/ export default function WidgetController($scope, $timeout, $window, $element, $q, $log, $injector, tbRaf, types, utils, - datasourceService, deviceService, visibleRect, isEdit, widget, deviceAliasList, widgetType) { + datasourceService, deviceService, visibleRect, isEdit, stDiff, widget, deviceAliasList, widgetType) { var vm = this; @@ -46,7 +46,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q realtimeWindowMs: null, aggregation: null }; - var dataUpdateTimer = null; var dataUpdateCaf = null; /* @@ -72,7 +71,9 @@ export default function WidgetController($scope, $timeout, $window, $element, $q settings: widget.config.settings, datasources: widget.config.datasources, data: [], - timeWindow: {}, + timeWindow: { + stDiff: stDiff + }, timewindowFunctions: { onUpdateTimewindow: onUpdateTimewindow, onResetTimewindow: onResetTimewindow @@ -154,10 +155,11 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } } - function updateTimewindow(startTs, endTs) { + function updateTimewindow() { + widgetContext.timeWindow.interval = subscriptionTimewindow.aggregation.interval || 1000; if (subscriptionTimewindow.realtimeWindowMs) { - widgetContext.timeWindow.maxTime = endTs || (new Date).getTime(); - widgetContext.timeWindow.minTime = startTs || (widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs); + widgetContext.timeWindow.maxTime = (new Date).getTime() + widgetContext.timeWindow.stDiff; + widgetContext.timeWindow.minTime = widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs; } else if (subscriptionTimewindow.fixedWindow) { widgetContext.timeWindow.maxTime = subscriptionTimewindow.fixedWindow.endTimeMs; widgetContext.timeWindow.minTime = subscriptionTimewindow.fixedWindow.startTimeMs; @@ -165,10 +167,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } function onDataUpdated() { - if (dataUpdateTimer) { - $timeout.cancel(dataUpdateTimer); - dataUpdateTimer = null; - } if (widgetContext.inited) { if (dataUpdateCaf) { dataUpdateCaf(); @@ -496,7 +494,8 @@ export default function WidgetController($scope, $timeout, $window, $element, $q startTimeMs: startTimeMs, endTimeMs: endTimeMs } - } + }, + aggregation: angular.copy(widget.config.timewindow.aggregation) }; } @@ -513,14 +512,10 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } if (update) { if (subscriptionTimewindow.realtimeWindowMs) { - updateTimewindow(sourceData.startTs, sourceData.endTs); + updateTimewindow(); } widgetContext.data[datasourceIndex + dataKeyIndex].data = sourceData.data; - if (widgetContext.data.length > 1 && !dataUpdateTimer) { - dataUpdateTimer = $timeout(onDataUpdated, 300, false); - } else { - onDataUpdated(); - } + onDataUpdated(); } } @@ -552,10 +547,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q function unsubscribe() { if (widget.type !== types.widgetType.rpc.value) { - if (dataUpdateTimer) { - $timeout.cancel(dataUpdateTimer); - dataUpdateTimer = null; - } for (var i in datasourceListeners) { var listener = datasourceListeners[i]; datasourceService.unsubscribeFromDatasource(listener); @@ -575,7 +566,7 @@ export default function WidgetController($scope, $timeout, $window, $element, $q }; if (widget.type === types.widgetType.timeseries.value && angular.isDefined(widget.config.timewindow)) { - + var timeWindow = 0; if (angular.isDefined(widget.config.timewindow.aggregation)) { subscriptionTimewindow.aggregation = { limit: widget.config.timewindow.aggregation.limit || 200, @@ -585,6 +576,8 @@ export default function WidgetController($scope, $timeout, $window, $element, $q if (angular.isDefined(widget.config.timewindow.realtime)) { subscriptionTimewindow.realtimeWindowMs = widget.config.timewindow.realtime.timewindowMs; + subscriptionTimewindow.startTs = (new Date).getTime() + widgetContext.timeWindow.stDiff - subscriptionTimewindow.realtimeWindowMs; + timeWindow = subscriptionTimewindow.realtimeWindowMs; } else if (angular.isDefined(widget.config.timewindow.history)) { if (angular.isDefined(widget.config.timewindow.history.timewindowMs)) { var currentTime = (new Date).getTime(); @@ -592,14 +585,31 @@ export default function WidgetController($scope, $timeout, $window, $element, $q startTimeMs: currentTime - widget.config.timewindow.history.timewindowMs, endTimeMs: currentTime } + timeWindow = widget.config.timewindow.history.timewindowMs; } else { subscriptionTimewindow.fixedWindow = { startTimeMs: widget.config.timewindow.history.fixedTimewindow.startTimeMs, endTimeMs: widget.config.timewindow.history.fixedTimewindow.endTimeMs } + timeWindow = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; } + subscriptionTimewindow.startTs = subscriptionTimewindow.fixedWindow.startTimeMs; + } + var aggregation = subscriptionTimewindow.aggregation; + var noAggregation = aggregation.type === types.aggregation.none.value; + var interval = Math.floor(timeWindow / aggregation.limit); + if (!noAggregation) { + aggregation.interval = Math.max(interval, 1000); + aggregation.limit = Math.ceil(interval/aggregation.interval * aggregation.limit); + aggregation.timeWindow = aggregation.interval * aggregation.limit; + } else { + aggregation.timeWindow = interval * aggregation.limit; + aggregation.interval = 1000; } updateTimewindow(); + if (subscriptionTimewindow.fixedWindow) { + onDataUpdated(); + } } for (var i in widget.config.datasources) { var datasource = widget.config.datasources[i]; diff --git a/ui/src/app/dashboard/dashboard.controller.js b/ui/src/app/dashboard/dashboard.controller.js index c557ed3286..c2a9cf316a 100644 --- a/ui/src/app/dashboard/dashboard.controller.js +++ b/ui/src/app/dashboard/dashboard.controller.js @@ -61,6 +61,7 @@ export default function DashboardController(types, widgetService, userService, vm.isTenantAdmin = isTenantAdmin; vm.isSystemAdmin = isSystemAdmin; vm.loadDashboard = loadDashboard; + vm.getServerTimeDiff = getServerTimeDiff; vm.noData = noData; vm.onAddWidgetClosed = onAddWidgetClosed; vm.onEditWidgetClosed = onEditWidgetClosed; @@ -94,10 +95,9 @@ export default function DashboardController(types, widgetService, userService, widgetService.getBundleWidgetTypes(bundleAlias, isSystem).then( function (widgetTypes) { - widgetTypes = $filter('orderBy')(widgetTypes, ['-name']); + widgetTypes = $filter('orderBy')(widgetTypes, ['-createdTime']); var top = 0; - var sizeY = 0; if (widgetTypes.length > 0) { loadNext(0); @@ -135,7 +135,7 @@ export default function DashboardController(types, widgetService, userService, } else if (widgetTypeInfo.type === types.widgetType.static.value) { vm.staticWidgetTypes.push(widget); } - top += sizeY; + top += widget.sizeY; loadNextOrComplete(i); } @@ -144,6 +144,10 @@ export default function DashboardController(types, widgetService, userService, } } + function getServerTimeDiff() { + return dashboardService.getServerTimeDiff(); + } + function loadDashboard() { var deferred = $q.defer(); diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html index 130a37bb67..298d365bb8 100644 --- a/ui/src/app/dashboard/dashboard.tpl.html +++ b/ui/src/app/dashboard/dashboard.tpl.html @@ -91,6 +91,7 @@ prepare-widget-context-menu="vm.prepareWidgetContextMenu(widget)" on-remove-widget="vm.removeWidget(event, widget)" load-widgets="vm.loadDashboard()" + get-st-diff="vm.getServerTimeDiff()" on-init="vm.dashboardInited(dashboard)" on-init-failed="vm.dashboardInitFailed(e)"> diff --git a/ui/src/app/device/attribute/attribute-table.directive.js b/ui/src/app/device/attribute/attribute-table.directive.js index d167928429..ba3e4662a7 100644 --- a/ui/src/app/device/attribute/attribute-table.directive.js +++ b/ui/src/app/device/attribute/attribute-table.directive.js @@ -29,7 +29,7 @@ import EditAttributeValueController from './edit-attribute-value.controller'; /*@ngInject*/ export default function AttributeTableDirective($compile, $templateCache, $rootScope, $q, $mdEditDialog, $mdDialog, - $document, $translate, utils, types, deviceService, widgetService) { + $document, $translate, utils, types, dashboardService, deviceService, widgetService) { var linker = function (scope, element, attrs) { @@ -357,6 +357,10 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS scope.getDeviceAttributes(true); } + scope.getServerTimeDiff = function() { + return dashboardService.getServerTimeDiff(); + } + scope.addWidgetToDashboard = function($event) { if (scope.mode === 'widget' && scope.widgetsListCache.length > 0) { var widget = scope.widgetsListCache[scope.widgetsCarousel.index][0]; diff --git a/ui/src/app/device/attribute/attribute-table.tpl.html b/ui/src/app/device/attribute/attribute-table.tpl.html index 2f88a272a1..e2efe36bf8 100644 --- a/ui/src/app/device/attribute/attribute-table.tpl.html +++ b/ui/src/app/device/attribute/attribute-table.tpl.html @@ -158,8 +158,9 @@ diff --git a/ui/src/app/widget/lib/flot-widget.js b/ui/src/app/widget/lib/flot-widget.js index 72401f9aee..c35b363fc8 100644 --- a/ui/src/app/widget/lib/flot-widget.js +++ b/ui/src/app/widget/lib/flot-widget.js @@ -22,6 +22,8 @@ import 'flot/src/jquery.flot'; import 'flot/src/plugins/jquery.flot.time'; import 'flot/src/plugins/jquery.flot.selection'; import 'flot/src/plugins/jquery.flot.pie'; +import 'flot/src/plugins/jquery.flot.crosshair'; +import 'flot/src/plugins/jquery.flot.stack'; /* eslint-disable angular/angularelement */ export default class TbFlot { @@ -38,8 +40,8 @@ export default class TbFlot { var keySettings = series.dataKey.settings; series.lines = { - fill: keySettings.fillLines || false, - show: keySettings.showLines || true + fill: keySettings.fillLines === true, + show: this.chartType === 'line' ? keySettings.showLines !== false : keySettings.showLines === true }; series.points = { @@ -58,36 +60,34 @@ export default class TbFlot { series.highlightColor = lineColor.toRgbString(); } - - var tbFlot = this; - ctx.tooltip = $('#flot-series-tooltip'); if (ctx.tooltip.length === 0) { ctx.tooltip = $("
"); ctx.tooltip.css({ fontSize: "12px", fontFamily: "Roboto", - lineHeight: "24px", + fontWeight: "300", + lineHeight: "18px", opacity: "1", backgroundColor: "rgba(0,0,0,0.7)", - color: "#fff", + color: "#D9DADB", position: "absolute", display: "none", zIndex: "100", - padding: "2px 8px", + padding: "4px 10px", borderRadius: "4px" }).appendTo("body"); } - ctx.tooltipFormatter = function(item) { - var label = item.series.label; - var color = item.series.color; - var content = ''; - if (tbFlot.chartType === 'line') { - var timestamp = parseInt(item.datapoint[0]); - var date = moment(timestamp).format('YYYY-MM-DD HH:mm:ss'); - content += '' + date + '
'; - } + var tbFlot = this; + + function seriesInfoDiv(label, color, value, units, trackDecimals, active, percent) { + var divElement = $('
'); + divElement.css({ + display: "flex", + alignItems: "center", + justifyContent: "center" + }); var lineSpan = $(''); lineSpan.css({ backgroundColor: color, @@ -97,27 +97,76 @@ export default class TbFlot { verticalAlign: "middle", marginRight: "5px" }); - content += lineSpan.prop('outerHTML'); - + divElement.append(lineSpan); var labelSpan = $('' + label + ':'); labelSpan.css({ marginRight: "10px" }); - content += labelSpan.prop('outerHTML'); - var value = tbFlot.chartType === 'line' ? item.datapoint[1] : item.datapoint[1][0][1]; - content += ' ' + value.toFixed(ctx.trackDecimals); - if (settings.units) { - content += ' ' + settings.units; + if (active) { + labelSpan.css({ + color: "#FFF", + fontWeight: "700" + }); } - if (tbFlot.chartType === 'pie') { - content += ' (' + Math.round(item.series.percent) + ' %)'; + divElement.append(labelSpan); + var valueContent = value.toFixed(trackDecimals); + if (units) { + valueContent += ' ' + units; } - content += ''; - return content; - }; + if (angular.isNumber(percent)) { + valueContent += ' (' + Math.round(percent) + ' %)'; + } + var valueSpan = $('' + valueContent + ''); + valueSpan.css({ + marginLeft: "auto", + fontWeight: "700" + }); + if (active) { + valueSpan.css({ + color: "#FFF" + }); + } + divElement.append(valueSpan); + + return divElement; + } + + if (this.chartType === 'pie') { + ctx.tooltipFormatter = function(item) { + var divElement = seriesInfoDiv(item.series.label, item.series.color, + item.datapoint[1][0][1], tbFlot.ctx.settings.units, tbFlot.ctx.trackDecimals, true, item.series.percent); + return divElement.prop('outerHTML'); + }; + } else { + ctx.tooltipFormatter = function(hoverInfo, seriesIndex) { + var content = ''; + var timestamp = parseInt(hoverInfo.time); + var date = moment(timestamp).format('YYYY-MM-DD HH:mm:ss'); + var dateDiv = $('
' + date + '
'); + dateDiv.css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "4px", + fontWeight: "700" + }); + content += dateDiv.prop('outerHTML'); + for (var i in hoverInfo.seriesHover) { + var seriesHoverInfo = hoverInfo.seriesHover[i]; + if (tbFlot.ctx.tooltipIndividual && seriesHoverInfo.index !== seriesIndex) { + continue; + } + var divElement = seriesInfoDiv(seriesHoverInfo.label, seriesHoverInfo.color, + seriesHoverInfo.value, tbFlot.ctx.settings.units, tbFlot.ctx.trackDecimals, seriesHoverInfo.index === seriesIndex); + content += divElement.prop('outerHTML'); + } + return content; + }; + } var settings = ctx.settings; ctx.trackDecimals = angular.isDefined(settings.decimals) ? settings.decimals : 1; + ctx.tooltipIndividual = this.chartType === 'pie' || (angular.isDefined(settings.tooltipIndividual) ? settings.tooltipIndividual : false); var font = { color: settings.fontColor || "#545454", @@ -134,7 +183,7 @@ export default class TbFlot { grid: { hoverable: true, mouseActiveRadius: 10, - autoHighlight: true + autoHighlight: ctx.tooltipIndividual === true }, selection : { mode : ctx.isMobile ? null : 'x' }, legend : { @@ -155,7 +204,7 @@ export default class TbFlot { settings.legend.backgroundOpacity : 0.85; } - if (this.chartType === 'line') { + if (this.chartType === 'line' || this.chartType === 'bar') { options.xaxis = { mode: 'time', timezone: 'browser', @@ -208,6 +257,28 @@ export default class TbFlot { } } + options.crosshair = { + mode: 'x' + } + + options.series = { + stack: settings.stack === true + } + + if (this.chartType === 'bar') { + options.series.lines = { + show: false, + fill: false, + steps: false + } + options.series.bars ={ + show: true, + barWidth: ctx.timeWindow.interval * 0.6, + lineWidth: 0, + fill: 0.9 + } + } + options.xaxis.min = ctx.timeWindow.minTime; options.xaxis.max = ctx.timeWindow.maxTime; } else if (this.chartType === 'pie') { @@ -271,11 +342,12 @@ export default class TbFlot { update() { if (!this.isMouseInteraction) { - if (this.chartType === 'line') { + if (this.chartType === 'line' || this.chartType === 'bar') { this.ctx.plot.getOptions().xaxes[0].min = this.ctx.timeWindow.minTime; this.ctx.plot.getOptions().xaxes[0].max = this.ctx.timeWindow.maxTime; - } - if (this.chartType === 'line') { + if (this.chartType === 'bar') { + this.ctx.plot.getOptions().series.bars.barWidth = this.ctx.timeWindow.interval * 0.6; + } this.ctx.plot.setData(this.ctx.data); this.ctx.plot.setupGrid(); this.ctx.plot.draw(); @@ -290,75 +362,475 @@ export default class TbFlot { } } - pieDataRendered() { - for (var i in this.ctx.pieTargetData) { - var value = this.ctx.pieTargetData[i] ? this.ctx.pieTargetData[i] : 0; - this.ctx.pieRenderedData[i] = value; - if (!this.ctx.pieData[i].data[0]) { - this.ctx.pieData[i].data[0] = [0,0]; - } - this.ctx.pieData[i].data[0][1] = value; + resize() { + this.ctx.plot.resize(); + if (this.chartType !== 'pie') { + this.ctx.plot.setupGrid(); } + this.ctx.plot.draw(); } - nextPieDataAnimation(start) { - if (start) { - this.finishPieDataAnimation(); - this.ctx.pieAnimationStartTime = this.ctx.pieAnimationLastTime = Date.now(); - for (var i in this.ctx.data) { - this.ctx.pieTargetData[i] = (this.ctx.data[i].data && this.ctx.data[i].data[0]) - ? this.ctx.data[i].data[0][1] : 0; - } - } - if (this.ctx.pieAnimationCaf) { - this.ctx.pieAnimationCaf(); - this.ctx.pieAnimationCaf = null; + static get pieSettingsSchema() { + return { + "schema": { + "type": "object", + "title": "Settings", + "properties": { + "radius": { + "title": "Radius", + "type": "number", + "default": 1 + }, + "innerRadius": { + "title": "Inner radius", + "type": "number", + "default": 0 + }, + "tilt": { + "title": "Tilt", + "type": "number", + "default": 1 + }, + "animatedPie": { + "title": "Enable pie animation (experimental)", + "type": "boolean", + "default": false + }, + "stroke": { + "title": "Stroke", + "type": "object", + "properties": { + "color": { + "title": "Color", + "type": "string", + "default": "" + }, + "width": { + "title": "Width (pixels)", + "type": "number", + "default": 0 + } + } + }, + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": false + }, + "fontColor": { + "title": "Font color", + "type": "string", + "default": "#545454" + }, + "fontSize": { + "title": "Font size", + "type": "number", + "default": 10 + }, + "decimals": { + "title": "Number of digits after floating point", + "type": "number", + "default": 1 + }, + "units": { + "title": "Special symbol to show next to value", + "type": "string", + "default": "" + }, + "legend": { + "title": "Legend settings", + "type": "object", + "properties": { + "show": { + "title": "Show legend", + "type": "boolean", + "default": true + }, + "position": { + "title": "Position", + "type": "string", + "default": "nw" + }, + "labelBoxBorderColor": { + "title": "Label box border color", + "type": "string", + "default": "#CCCCCC" + }, + "backgroundColor": { + "title": "Background color", + "type": "string", + "default": "#F0F0F0" + }, + "backgroundOpacity": { + "title": "Background opacity", + "type": "number", + "default": 0.85 + } + } + } + }, + "required": [] + }, + "form": [ + "radius", + "innerRadius", + "animatedPie", + "tilt", + { + "key": "stroke", + "items": [ + { + "key": "stroke.color", + "type": "color" + }, + "stroke.width" + ] + }, + "showLabels", + { + "key": "fontColor", + "type": "color" + }, + "fontSize", + "decimals", + "units", + { + "key": "legend", + "items": [ + "legend.show", + { + "key": "legend.position", + "type": "rc-select", + "multiple": false, + "items": [ + { + "value": "nw", + "label": "North-west" + }, + { + "value": "ne", + "label": "North-east" + }, + { + "value": "sw", + "label": "South-west" + }, + { + "value": "se", + "label": "Soth-east" + } + ] + }, + { + "key": "legend.labelBoxBorderColor", + "type": "color" + }, + { + "key": "legend.backgroundColor", + "type": "color" + }, + "legend.backgroundOpacity" + ] + } + ] } - var self = this; - this.ctx.pieAnimationCaf = this.ctx.$scope.tbRaf( - function () { - self.onPieDataAnimation(); - } - ); } - onPieDataAnimation() { - var time = Date.now(); - var elapsed = time - this.ctx.pieAnimationLastTime;//this.ctx.pieAnimationStartTime; - var progress = (time - this.ctx.pieAnimationStartTime) / this.ctx.pieDataAnimationDuration; - if (progress >= 1) { - this.finishPieDataAnimation(); - } else { - if (elapsed >= 40) { - for (var i in this.ctx.pieTargetData) { - var prevValue = this.ctx.pieRenderedData[i]; - var targetValue = this.ctx.pieTargetData[i]; - var value = prevValue + (targetValue - prevValue) * progress; - if (!this.ctx.pieData[i].data[0]) { - this.ctx.pieData[i].data[0] = [0,0]; + static get settingsSchema() { + return { + "schema": { + "type": "object", + "title": "Settings", + "properties": { + "stack": { + "title": "Stacking", + "type": "boolean", + "default": false + }, + "shadowSize": { + "title": "Shadow size", + "type": "number", + "default": 4 + }, + "fontColor": { + "title": "Font color", + "type": "string", + "default": "#545454" + }, + "fontSize": { + "title": "Font size", + "type": "number", + "default": 10 + }, + "decimals": { + "title": "Number of digits after floating point", + "type": "number", + "default": 1 + }, + "units": { + "title": "Special symbol to show next to value", + "type": "string", + "default": "" + }, + "tooltipIndividual": { + "title": "Hover individual points", + "type": "boolean", + "default": false + }, + "grid": { + "title": "Grid settings", + "type": "object", + "properties": { + "color": { + "title": "Primary color", + "type": "string", + "default": "#545454" + }, + "backgroundColor": { + "title": "Background color", + "type": "string", + "default": null + }, + "tickColor": { + "title": "Ticks color", + "type": "string", + "default": "#DDDDDD" + }, + "outlineWidth": { + "title": "Grid outline/border width (px)", + "type": "number", + "default": 1 + }, + "verticalLines": { + "title": "Show vertical lines", + "type": "boolean", + "default": true + }, + "horizontalLines": { + "title": "Show horizontal lines", + "type": "boolean", + "default": true + } + } + }, + "legend": { + "title": "Legend settings", + "type": "object", + "properties": { + "show": { + "title": "Show legend", + "type": "boolean", + "default": true + }, + "position": { + "title": "Position", + "type": "string", + "default": "nw" + }, + "labelBoxBorderColor": { + "title": "Label box border color", + "type": "string", + "default": "#CCCCCC" + }, + "backgroundColor": { + "title": "Background color", + "type": "string", + "default": "#F0F0F0" + }, + "backgroundOpacity": { + "title": "Background opacity", + "type": "number", + "default": 0.85 + } + } + }, + "xaxis": { + "title": "X axis settings", + "type": "object", + "properties": { + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": true + }, + "title": { + "title": "Axis title", + "type": "string", + "default": null + }, + "titleAngle": { + "title": "Axis title's angle in degrees", + "type": "number", + "default": 0 + }, + "color": { + "title": "Ticks color", + "type": "string", + "default": null + } + } + }, + "yaxis": { + "title": "Y axis settings", + "type": "object", + "properties": { + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": true + }, + "title": { + "title": "Axis title", + "type": "string", + "default": null + }, + "titleAngle": { + "title": "Axis title's angle in degrees", + "type": "number", + "default": 0 + }, + "color": { + "title": "Ticks color", + "type": "string", + "default": null + } + } } - this.ctx.pieData[i].data[0][1] = value; + }, + "required": [] + }, + "form": [ + "stack", + "shadowSize", + { + "key": "fontColor", + "type": "color" + }, + "fontSize", + "decimals", + "units", + "tooltipIndividual", + { + "key": "grid", + "items": [ + { + "key": "grid.color", + "type": "color" + }, + { + "key": "grid.backgroundColor", + "type": "color" + }, + { + "key": "grid.tickColor", + "type": "color" + }, + "grid.outlineWidth", + "grid.verticalLines", + "grid.horizontalLines" + ] + }, + { + "key": "legend", + "items": [ + "legend.show", + { + "key": "legend.position", + "type": "rc-select", + "multiple": false, + "items": [ + { + "value": "nw", + "label": "North-west" + }, + { + "value": "ne", + "label": "North-east" + }, + { + "value": "sw", + "label": "South-west" + }, + { + "value": "se", + "label": "Soth-east" + } + ] + }, + { + "key": "legend.labelBoxBorderColor", + "type": "color" + }, + { + "key": "legend.backgroundColor", + "type": "color" + }, + "legend.backgroundOpacity" + ] + }, + { + "key": "xaxis", + "items": [ + "xaxis.showLabels", + "xaxis.title", + "xaxis.titleAngle", + { + "key": "xaxis.color", + "type": "color" + } + ] + }, + { + "key": "yaxis", + "items": [ + "yaxis.showLabels", + "yaxis.title", + "yaxis.titleAngle", + { + "key": "yaxis.color", + "type": "color" + } + ] } - this.ctx.plot.setData(this.ctx.pieData); - this.ctx.plot.draw(); - this.ctx.pieAnimationLastTime = time; - } - this.nextPieDataAnimation(false); + + ] } } - finishPieDataAnimation() { - this.pieDataRendered(); - this.ctx.plot.setData(this.ctx.pieData); - this.ctx.plot.draw(); + static get pieDatakeySettingsSchema() { + return {} } - resize() { - this.ctx.plot.resize(); - if (this.chartType === 'line') { - this.ctx.plot.setupGrid(); + static datakeySettingsSchema(defaultShowLines) { + return { + "schema": { + "type": "object", + "title": "DataKeySettings", + "properties": { + "showLines": { + "title": "Show lines", + "type": "boolean", + "default": defaultShowLines + }, + "fillLines": { + "title": "Fill lines", + "type": "boolean", + "default": false + }, + "showPoints": { + "title": "Show points", + "type": "boolean", + "default": false + } + }, + "required": ["showLines", "fillLines", "showPoints"] + }, + "form": [ + "showLines", + "fillLines", + "showPoints" + ] } - this.ctx.plot.draw(); } checkMouseEvents() { @@ -378,24 +850,58 @@ export default class TbFlot { if (!this.flotHoverHandler) { this.flotHoverHandler = function (event, pos, item) { - if (item) { - var pageX = item.pageX || pos.pageX; - var pageY = item.pageY || pos.pageY; - tbFlot.ctx.tooltip.html(tbFlot.ctx.tooltipFormatter(item)) - .css({top: pageY+5, left: 0}) - .fadeIn(200); - var windowWidth = $( window ).width(); //eslint-disable-line - var tooltipWidth = tbFlot.ctx.tooltip.width(); - var left = pageX+5; - if (windowWidth - pageX < tooltipWidth + 50) { - left = pageX - tooltipWidth - 10; + if (!tbFlot.ctx.tooltipIndividual || item) { + + var multipleModeTooltip = !tbFlot.ctx.tooltipIndividual; + + if (multipleModeTooltip) { + tbFlot.ctx.plot.unhighlight(); } - tbFlot.ctx.tooltip.css({ - left: left - }); + + var pageX = pos.pageX; + var pageY = pos.pageY; + + var tooltipHtml; + + if (tbFlot.chartType === 'pie') { + tooltipHtml = tbFlot.ctx.tooltipFormatter(item); + } else { + var hoverInfo = tbFlot.getHoverInfo(tbFlot.ctx.plot.getData(), pos); + if (angular.isNumber(hoverInfo.time)) { + hoverInfo.seriesHover.sort(function (a, b) { + return b.value - a.value; + }); + tooltipHtml = tbFlot.ctx.tooltipFormatter(hoverInfo, item ? item.seriesIndex : -1); + } + } + + if (tooltipHtml) { + tbFlot.ctx.tooltip.html(tooltipHtml) + .css({top: pageY+5, left: 0}) + .fadeIn(200); + + var windowWidth = $( window ).width(); //eslint-disable-line + var tooltipWidth = tbFlot.ctx.tooltip.width(); + var left = pageX+5; + if (windowWidth - pageX < tooltipWidth + 50) { + left = pageX - tooltipWidth - 10; + } + tbFlot.ctx.tooltip.css({ + left: left + }); + + if (multipleModeTooltip) { + for (var i = 0; i < hoverInfo.seriesHover.length; i++) { + var seriesHoverInfo = hoverInfo.seriesHover[i]; + tbFlot.ctx.plot.highlight(seriesHoverInfo.index, seriesHoverInfo.hoverIndex); + } + } + } + } else { tbFlot.ctx.tooltip.stop(true); tbFlot.ctx.tooltip.hide(); + tbFlot.ctx.plot.unhighlight(); } }; this.ctx.$container.bind('plothover', this.flotHoverHandler); @@ -430,6 +936,7 @@ export default class TbFlot { this.mouseleaveHandler = function () { tbFlot.ctx.tooltip.stop(true); tbFlot.ctx.tooltip.hide(); + tbFlot.ctx.plot.unhighlight(); tbFlot.isMouseInteraction = false; }; this.ctx.$container.bind('mouseleave', this.mouseleaveHandler); @@ -467,6 +974,152 @@ export default class TbFlot { this.mouseleaveHandler = null; } } + + + findHoverIndexFromData (posX, series) { + var lower = 0; + var upper = series.data.length - 1; + var middle; + var index = null; + while (index === null) { + if (lower > upper) { + return Math.max(upper, 0); + } + middle = Math.floor((lower + upper) / 2); + if (series.data[middle][0] === posX) { + return middle; + } else if (series.data[middle][0] < posX) { + lower = middle + 1; + } else { + upper = middle - 1; + } + } + } + + findHoverIndexFromDataPoints (posX, series, last) { + var ps = series.datapoints.pointsize; + var initial = last*ps; + var len = series.datapoints.points.length; + for (var j = initial; j < len; j += ps) { + if ((!series.lines.steps && series.datapoints.points[initial] != null && series.datapoints.points[j] == null) + || series.datapoints.points[j] > posX) { + return Math.max(j - ps, 0)/ps; + } + } + return j/ps - 1; + } + + + getHoverInfo (seriesList, pos) { + var i, series, value, hoverIndex, hoverDistance, pointTime, minDistance, minTime; + var last_value = 0; + var results = { + seriesHover: [] + }; + for (i = 0; i < seriesList.length; i++) { + series = seriesList[i]; + hoverIndex = this.findHoverIndexFromData(pos.x, series); + if (series.data[hoverIndex] && series.data[hoverIndex][0]) { + hoverDistance = pos.x - series.data[hoverIndex][0]; + pointTime = series.data[hoverIndex][0]; + + if (!minDistance + || (hoverDistance >= 0 && (hoverDistance < minDistance || minDistance < 0)) + || (hoverDistance < 0 && hoverDistance > minDistance)) { + minDistance = hoverDistance; + minTime = pointTime; + } + if (series.stack) { + if (this.ctx.tooltipIndividual) { + value = series.data[hoverIndex][1]; + } else { + last_value += series.data[hoverIndex][1]; + value = last_value; + } + } else { + value = series.data[hoverIndex][1]; + } + + if (series.stack) { + hoverIndex = this.findHoverIndexFromDataPoints(pos.x, series, hoverIndex); + } + results.seriesHover.push({ + value: value, + hoverIndex: hoverIndex, + color: series.dataKey.color, + label: series.label, + time: pointTime, + distance: hoverDistance, + index: i + }); + } + } + results.time = minTime; + return results; + } + + pieDataRendered() { + for (var i in this.ctx.pieTargetData) { + var value = this.ctx.pieTargetData[i] ? this.ctx.pieTargetData[i] : 0; + this.ctx.pieRenderedData[i] = value; + if (!this.ctx.pieData[i].data[0]) { + this.ctx.pieData[i].data[0] = [0,0]; + } + this.ctx.pieData[i].data[0][1] = value; + } + } + + nextPieDataAnimation(start) { + if (start) { + this.finishPieDataAnimation(); + this.ctx.pieAnimationStartTime = this.ctx.pieAnimationLastTime = Date.now(); + for (var i in this.ctx.data) { + this.ctx.pieTargetData[i] = (this.ctx.data[i].data && this.ctx.data[i].data[0]) + ? this.ctx.data[i].data[0][1] : 0; + } + } + if (this.ctx.pieAnimationCaf) { + this.ctx.pieAnimationCaf(); + this.ctx.pieAnimationCaf = null; + } + var self = this; + this.ctx.pieAnimationCaf = this.ctx.$scope.tbRaf( + function () { + self.onPieDataAnimation(); + } + ); + } + + onPieDataAnimation() { + var time = Date.now(); + var elapsed = time - this.ctx.pieAnimationLastTime;//this.ctx.pieAnimationStartTime; + var progress = (time - this.ctx.pieAnimationStartTime) / this.ctx.pieDataAnimationDuration; + if (progress >= 1) { + this.finishPieDataAnimation(); + } else { + if (elapsed >= 40) { + for (var i in this.ctx.pieTargetData) { + var prevValue = this.ctx.pieRenderedData[i]; + var targetValue = this.ctx.pieTargetData[i]; + var value = prevValue + (targetValue - prevValue) * progress; + if (!this.ctx.pieData[i].data[0]) { + this.ctx.pieData[i].data[0] = [0,0]; + } + this.ctx.pieData[i].data[0][1] = value; + } + this.ctx.plot.setData(this.ctx.pieData); + this.ctx.plot.draw(); + this.ctx.pieAnimationLastTime = time; + } + this.nextPieDataAnimation(false); + } + } + + finishPieDataAnimation() { + this.pieDataRendered(); + this.ctx.plot.setData(this.ctx.pieData); + this.ctx.plot.draw(); + } } /* eslint-enable angular/angularelement */ \ No newline at end of file diff --git a/ui/src/app/widget/widget-library.controller.js b/ui/src/app/widget/widget-library.controller.js index f7047e4e79..d07233ab96 100644 --- a/ui/src/app/widget/widget-library.controller.js +++ b/ui/src/app/widget/widget-library.controller.js @@ -54,7 +54,7 @@ export default function WidgetLibraryController($scope, $rootScope, $q, widgetSe widgetService.getBundleWidgetTypes(bundleAlias, isSystem).then( function (widgetTypes) { - widgetTypes = $filter('orderBy')(widgetTypes, ['-descriptor.type','name']); + widgetTypes = $filter('orderBy')(widgetTypes, ['-descriptor.type','-createdTime']); var top = 0; var lastTop = [0, 0, 0]; From 7769dd1c59c59f42925ef71f50d400065d821650 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 27 Feb 2017 15:18:14 +0200 Subject: [PATCH 10/14] UI: Update widgets. --- dao/src/main/resources/system-data.cql | 53 ++++++++++++++------------ 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/dao/src/main/resources/system-data.cql b/dao/src/main/resources/system-data.cql index b8fe9a3a9d..e7ccc56d1b 100644 --- a/dao/src/main/resources/system-data.cql +++ b/dao/src/main/resources/system-data.cql @@ -62,6 +62,15 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'cards', 'Cards' ); INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', 'analogue gauges', 'Analogue gauges' ); +INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) +VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries', +'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.4.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var lineData = {\n labels: [],\n datasets: []\n };\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n var keySettings = dataKey.settings;\n var backgroundColor = tinycolor(dataKey.color);\n backgroundColor.setAlpha(0.4);\n var dataset = {\n label: dataKey.label,\n data: [],\n borderColor: dataKey.color,\n borderWidth: 2,\n backgroundColor: backgroundColor.toRgbString(),\n pointRadius: keySettings.showPoints ? 1 : 0,\n fill: keySettings.fillLines || false,\n showLine: keySettings.showLines || true,\n spanGaps: false,\n lineTension: angular.isDefined(keySettings.tension) ? keySettings.tension : 0.2\n }\n lineData.datasets.push(dataset);\n }\n\n var ctx = $(''#lineChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''line'',\n data: lineData,\n options: {\n responsive: false,\n tooltips: {\n mode: ''index'',\n inersect: true\n },\n hover: {\n mode: ''index'',\n inersect: true\n },\n maintainAspectRatio: false,\n /*animation: {\n duration: 200,\n easing: ''linear''\n },*/\n elements: {\n line: {\n tension: 0.2\n } \n },\n scales: {\n xAxes: [{\n type: ''time'',\n ticks: {\n maxRotation: 20,\n autoSkip: true\n },\n time: {\n displayFormats: {\n second: ''hh:mm:ss'',\n minute: ''hh:mm:ss''\n }\n }\n }]\n },\n zoom: {\n onSelect: function(startTimeMs, endTimeMs) {\n self.ctx.timewindowFunctions.onUpdateTimewindow(startTimeMs, endTimeMs);\n },\n onResetSelect: function() {\n self.ctx.timewindowFunctions.onResetTimewindow();\n }\n }\n }\n });\n \n self.onResize();\n \n}\n\nself.onDataUpdated = function() {\n \n if (self.ctx.chart.zoom.isMouseInteraction) {\n return;\n }\n if (!self.ctx.tickUpdate) {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataSetData = [];\n var dataKeyData = self.ctx.data[i].data;\n for (var i2 = 0; i2 < dataKeyData.length; i2 ++) {\n dataSetData.push({x: moment(dataKeyData[i2][0]), y: dataKeyData[i2][1]});\n \n }\n self.ctx.chart.data.datasets[i].data = dataSetData; \n }\n }\n \n self.ctx.chart.options.scales.xAxes[0].time.min = moment(self.ctx.timeWindow.minTime);\n self.ctx.chart.options.scales.xAxes[0].time.max = moment(self.ctx.timeWindow.maxTime);\n \n self.ctx.chart.update(0, true);\n\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n self.ctx.chart.update(0, true);\n}\n\nself.onDestroy = function() {\n}\n\nfunction getYAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (!scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction getXAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction eventPointer (e) {\n if (angular.isDefined(e.touches) && e.touches.length > 0) {\n return {\n x : e.touches[0].pageX,\n y : e.touches[0].pageY\n };\n } else if (angular.isDefined(e.changedTouches) && e.changedTouches.length > 0) {\n return {\n x : e.changedTouches[0].pageX,\n y : e.changedTouches[0].pageY\n };\n } else if (e.pageX || e.pageY) {\n return {\n x : e.pageX,\n y : e.pageY\n };\n } else if (e.clientX || e.clientY) {\n var\n d = document,\n b = d.body,\n de = d.documentElement;\n return {\n x: e.clientX + b.scrollLeft + de.scrollLeft,\n y: e.clientY + b.scrollTop + de.scrollTop\n };\n }\n}\n\nvar zoomPlugin = {\n beforeInit: function(chartInstance) {\n chartInstance.zoom = {};\n var node = chartInstance.zoom.node = chartInstance.chart.ctx.canvas;\n \n chartInstance.zoom.mouseDownHandler = function(event) {\n chartInstance.zoom.dragZoomStart = event;\n chartInstance.zoom.dragZoomStartPointer = eventPointer(event);\n chartInstance.zoom.isMouseInteraction = true;\n };\n\n node.addEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n \n chartInstance.zoom.mouseMoveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n chartInstance.update(0);\n chartInstance.zoom.dragZoomEnd = event;\n chartInstance.zoom.dragZoomEndPointer = eventPointer(event);\n }\n };\n \n node.addEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n \n chartInstance.zoom.mouseUpHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \n var chartArea = chartInstance.chartArea;\n var yAxis = getYAxis(chartInstance);\n\t\t\t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n\t\t\t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n\t\t\t\t\tvar upEventPointer = eventPointer(event);\n\t\t\t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n\t\t\t\t\tvar startX = Math.min(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar endX = Math.max(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar dragDistance = endX - startX;\n\t\t\t\t\t\n\t\t\t\t\tif (dragDistance > 0) {\n \t\t\t\t\tvar xAxis = getXAxis(chartInstance);\n \t\t\t\t\tvar options = chartInstance.options;\n \t\t\t\t\tif (options.scales.xAxes[0].time) {\n \t\t\t\t\t startX = Math.max(startX, xAxis.left);\n \t\t\t\t\t endX = Math.min(endX, xAxis.right);\n \t\t\t\t\t if (endX - startX > 0) {\n \t\t\t\t\t startX = startX - xAxis.left;\n \t\t\t\t\t endX = endX - xAxis.left;\n \t\t\t\t\t var time = options.scales.xAxes[0].time;\n \t\t\t\t\t var min = time.min.valueOf();\n \t\t\t\t\t var max = time.max.valueOf();\n \t\t\t\t\t var axisDistance = xAxis.right - xAxis.left;\n \t\t\t\t\t var timeDistance = max - min;\n \t\t\t\t\t \n \t\t\t\t\t var zoomStartTime = min + startX / axisDistance * timeDistance;\n \t\t\t\t\t var zoomEndTime = min + endX / axisDistance * timeDistance;\n\n \t\t\t\t\t if (options.zoom && options.zoom.onSelect) {\n \t\t\t\t\t options.zoom.onSelect(zoomStartTime, zoomEndTime);\n \t\t\t\t\t }\n \t\t\t\t\t }\n \t\t\t\t\t}\n\t\t\t\t\t}\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n \n chartInstance.zoom.mouseLeaveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\n \n chartInstance.zoom.dblClickHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n var options = chartInstance.options;\n if (options.zoom && options.zoom.onResetSelect) {\n options.zoom.onResetSelect();\n }\n };\n \n node.addEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n },\n beforeDatasetsDraw: function(chartInstance, easing) {\n \t\tvar ctx = chartInstance.chart.ctx;\n \t\tvar chartArea = chartInstance.chartArea;\n \t\tctx.save();\n \t\tctx.beginPath();\n \t\tif (chartInstance.zoom && chartInstance.zoom.dragZoomEnd) {\n \t\t\tvar yAxis = getYAxis(chartInstance);\n \t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n \t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n \t\t\tvar endPoint = chartInstance.zoom.dragZoomEnd;\n \t\t\tvar endPointer = chartInstance.zoom.dragZoomEndPointer;\n \t\t\t\n \t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n \t\t\tvar startX = Math.min(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar endX = Math.max(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar rectWidth = endX - startX;\n \t\t\tctx.fillStyle = ''rgba(157,203,255,0.1)'';\n \t\t\tctx.lineWidth = 1;\n \t\t\tctx.strokeRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t\tctx.fillRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t}\n \t\tif (chartArea) {\n \t\t ctx.rect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);\n \t\t}\n\t\t ctx.clip(); \n },\n \n afterDatasetsDraw: function(chartInstance) {\n\t chartInstance.chart.ctx.restore();\n },\n \n destroy: function(chartInstance) {\n if (chartInstance.zoom) {\n var node = chartInstance.zoom.node;\n\t\t\t\tnode.removeEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n\t\t\t\tnode.removeEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n\t\t\t\tnode.removeEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n\t\t\t\tnode.removeEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\t \n\t\t\t\tnode.removeEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n\t\t\t\tdelete chartInstance.zoom;\n }\n }\n };\n\nChart.pluginService.register(zoomPlugin);\n","settingsSchema":"{}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"showLines\": {\n \"title\": \"Show lines\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"fillLines\": {\n \"title\": \"Fill lines\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"showPoints\": {\n \"title\": \"Show points\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"tension\": {\n \"title\": \"Line tension\",\n \"type\": \"number\",\n \"default\": 0.2\n }\n },\n \"required\": [\"showLines\", \"fillLines\", \"showPoints\"]\n },\n \"form\": [\n \"showLines\",\n \"fillLines\",\n \"showPoints\",\n \"tension\"\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.5644745944820795,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.18379294198604845,\"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\":\"Timeseries - Chart.js (Deprecated)\"}"}', +'Timeseries - Chart.js (Deprecated)' ); + +INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) +VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'bars', +'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n \n for (var i in self.ctx.datasources) {\n var datasource = self.ctx.datasources[i];\n for (i in datasource.dataKeys) {\n var dataset = {\n label: datasource.dataKeys[i].label,\n data: [0],\n backgroundColor: [datasource.dataKeys[i].color],\n borderColor: [datasource.dataKeys[i].color],\n borderWidth: 1\n }\n barData.datasets.push(dataset);\n }\n }\n\n var ctx = $(''#barChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''bar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false,\n scales: {\n yAxes: [{\n ticks: {\n beginAtZero:true\n }\n }]\n }\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var c = 0;\n for (var i = 0; i < self.ctx.chart.data.datasets.length; i++) {\n var dataset = self.ctx.chart.data.datasets[i];\n var cellData = self.ctx.data[i]; \n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n dataset.data[0] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Bars - Chart.js\"}"}', +'Bars - Chart.js' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'gpio_panel', @@ -87,7 +96,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'lcd_bar_gauge', INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'radar_chart_js', -'{"type":"latest","sizeX":7,"sizeY":6,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n\n var backgroundColor = tinycolor(self.ctx.data[0].dataKey.color);\n backgroundColor.setAlpha(0.2);\n var borderColor = tinycolor(self.ctx.data[0].dataKey.color);\n borderColor.setAlpha(1);\n var dataset = {\n label: self.ctx.datasources[0].name,\n data: [],\n backgroundColor: backgroundColor.toRgbString(),\n borderColor: borderColor.toRgbString(),\n pointBackgroundColor: borderColor.toRgbString(),\n pointBorderColor: borderColor.darken().toRgbString(),\n borderWidth: 1\n }\n \n barData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n barData.labels.push(dataKey.label);\n dataset.data.push(0);\n }\n\n var ctx = $(''#radarChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''radar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n } \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n self.ctx.chart.resize();\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Radar - Chart.js\"}"}', +'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n\n var backgroundColor = tinycolor(self.ctx.data[0].dataKey.color);\n backgroundColor.setAlpha(0.2);\n var borderColor = tinycolor(self.ctx.data[0].dataKey.color);\n borderColor.setAlpha(1);\n var dataset = {\n label: self.ctx.datasources[0].name,\n data: [],\n backgroundColor: backgroundColor.toRgbString(),\n borderColor: borderColor.toRgbString(),\n pointBackgroundColor: borderColor.toRgbString(),\n pointBorderColor: borderColor.darken().toRgbString(),\n borderWidth: 1\n }\n \n barData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n barData.labels.push(dataKey.label);\n dataset.data.push(0);\n }\n\n var ctx = $(''#radarChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''radar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n } \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n self.ctx.chart.resize();\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Radar - Chart.js\"}"}', 'Radar - Chart.js' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) @@ -102,22 +111,12 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', '{"type":"latest","sizeX":7,"sizeY":3,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueLinearGauge(self.ctx, ''linearGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueLinearGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 10 - 5;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":100,\"defaultColor\":\"#e64a19\",\"barStrokeWidth\":2.5,\"colorBar\":\"#fff\",\"colorBarEnd\":\"#ddd\",\"showUnitTitle\":true,\"minorTicks\":2,\"valueBox\":true,\"valueInt\":3,\"valueDec\":0,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"colorNeedleShadowUp\":\"rgba(2,255,255,0.2)\",\"colorNeedleShadowDown\":\"rgba(188,143,143,0.45)\",\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\",\"highlightsWidth\":10,\"animation\":true,\"animationDuration\":500,\"animationRule\":\"cycle\",\"showBorder\":false,\"majorTicksCount\":10,\"numbersFont\":{\"family\":\"Roboto\",\"size\":18,\"style\":\"normal\",\"weight\":\"500\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#888\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":22,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#888\"},\"valueFont\":{\"family\":\"Roboto\",\"size\":40,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#444\",\"shadowColor\":\"rgba(0,0,0,0.3)\"},\"minValue\":-100,\"highlights\":[]},\"title\":\"Linear gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Linear gauge - Canvas Gauges' ); -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":6,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.4.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var lineData = {\n labels: [],\n datasets: []\n };\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n var keySettings = dataKey.settings;\n var backgroundColor = tinycolor(dataKey.color);\n backgroundColor.setAlpha(0.4);\n var dataset = {\n label: dataKey.label,\n data: [],\n borderColor: dataKey.color,\n borderWidth: 2,\n backgroundColor: backgroundColor.toRgbString(),\n pointRadius: keySettings.showPoints ? 1 : 0,\n fill: keySettings.fillLines || false,\n showLine: keySettings.showLines || true,\n spanGaps: false,\n lineTension: angular.isDefined(keySettings.tension) ? keySettings.tension : 0.2\n }\n lineData.datasets.push(dataset);\n }\n\n var ctx = $(''#lineChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''line'',\n data: lineData,\n options: {\n responsive: false,\n tooltips: {\n mode: ''index'',\n inersect: true\n },\n hover: {\n mode: ''index'',\n inersect: true\n },\n maintainAspectRatio: false,\n /*animation: {\n duration: 200,\n easing: ''linear''\n },*/\n elements: {\n line: {\n tension: 0.2\n } \n },\n scales: {\n xAxes: [{\n type: ''time'',\n ticks: {\n maxRotation: 20,\n autoSkip: true\n },\n time: {\n displayFormats: {\n second: ''hh:mm:ss'',\n minute: ''hh:mm:ss''\n }\n }\n }]\n },\n zoom: {\n onSelect: function(startTimeMs, endTimeMs) {\n self.ctx.timewindowFunctions.onUpdateTimewindow(startTimeMs, endTimeMs);\n },\n onResetSelect: function() {\n self.ctx.timewindowFunctions.onResetTimewindow();\n }\n }\n }\n });\n \n self.onResize();\n \n}\n\nself.onDataUpdated = function() {\n \n if (self.ctx.chart.zoom.isMouseInteraction) {\n return;\n }\n if (!self.ctx.tickUpdate) {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataSetData = [];\n var dataKeyData = self.ctx.data[i].data;\n for (var i2 = 0; i2 < dataKeyData.length; i2 ++) {\n dataSetData.push({x: moment(dataKeyData[i2][0]), y: dataKeyData[i2][1]});\n \n }\n self.ctx.chart.data.datasets[i].data = dataSetData; \n }\n }\n \n self.ctx.chart.options.scales.xAxes[0].time.min = moment(self.ctx.timeWindow.minTime);\n self.ctx.chart.options.scales.xAxes[0].time.max = moment(self.ctx.timeWindow.maxTime);\n \n self.ctx.chart.update(0, true);\n\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n self.ctx.chart.update(0, true);\n}\n\nself.onDestroy = function() {\n}\n\nfunction getYAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (!scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction getXAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction eventPointer (e) {\n if (angular.isDefined(e.touches) && e.touches.length > 0) {\n return {\n x : e.touches[0].pageX,\n y : e.touches[0].pageY\n };\n } else if (angular.isDefined(e.changedTouches) && e.changedTouches.length > 0) {\n return {\n x : e.changedTouches[0].pageX,\n y : e.changedTouches[0].pageY\n };\n } else if (e.pageX || e.pageY) {\n return {\n x : e.pageX,\n y : e.pageY\n };\n } else if (e.clientX || e.clientY) {\n var\n d = document,\n b = d.body,\n de = d.documentElement;\n return {\n x: e.clientX + b.scrollLeft + de.scrollLeft,\n y: e.clientY + b.scrollTop + de.scrollTop\n };\n }\n}\n\nvar zoomPlugin = {\n beforeInit: function(chartInstance) {\n chartInstance.zoom = {};\n var node = chartInstance.zoom.node = chartInstance.chart.ctx.canvas;\n \n chartInstance.zoom.mouseDownHandler = function(event) {\n chartInstance.zoom.dragZoomStart = event;\n chartInstance.zoom.dragZoomStartPointer = eventPointer(event);\n chartInstance.zoom.isMouseInteraction = true;\n };\n\n node.addEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n \n chartInstance.zoom.mouseMoveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n chartInstance.update(0);\n chartInstance.zoom.dragZoomEnd = event;\n chartInstance.zoom.dragZoomEndPointer = eventPointer(event);\n }\n };\n \n node.addEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n \n chartInstance.zoom.mouseUpHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \n var chartArea = chartInstance.chartArea;\n var yAxis = getYAxis(chartInstance);\n\t\t\t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n\t\t\t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n\t\t\t\t\tvar upEventPointer = eventPointer(event);\n\t\t\t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n\t\t\t\t\tvar startX = Math.min(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar endX = Math.max(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar dragDistance = endX - startX;\n\t\t\t\t\t\n\t\t\t\t\tif (dragDistance > 0) {\n \t\t\t\t\tvar xAxis = getXAxis(chartInstance);\n \t\t\t\t\tvar options = chartInstance.options;\n \t\t\t\t\tif (options.scales.xAxes[0].time) {\n \t\t\t\t\t startX = Math.max(startX, xAxis.left);\n \t\t\t\t\t endX = Math.min(endX, xAxis.right);\n \t\t\t\t\t if (endX - startX > 0) {\n \t\t\t\t\t startX = startX - xAxis.left;\n \t\t\t\t\t endX = endX - xAxis.left;\n \t\t\t\t\t var time = options.scales.xAxes[0].time;\n \t\t\t\t\t var min = time.min.valueOf();\n \t\t\t\t\t var max = time.max.valueOf();\n \t\t\t\t\t var axisDistance = xAxis.right - xAxis.left;\n \t\t\t\t\t var timeDistance = max - min;\n \t\t\t\t\t \n \t\t\t\t\t var zoomStartTime = min + startX / axisDistance * timeDistance;\n \t\t\t\t\t var zoomEndTime = min + endX / axisDistance * timeDistance;\n\n \t\t\t\t\t if (options.zoom && options.zoom.onSelect) {\n \t\t\t\t\t options.zoom.onSelect(zoomStartTime, zoomEndTime);\n \t\t\t\t\t }\n \t\t\t\t\t }\n \t\t\t\t\t}\n\t\t\t\t\t}\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n \n chartInstance.zoom.mouseLeaveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\n \n chartInstance.zoom.dblClickHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n var options = chartInstance.options;\n if (options.zoom && options.zoom.onResetSelect) {\n options.zoom.onResetSelect();\n }\n };\n \n node.addEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n },\n beforeDatasetsDraw: function(chartInstance, easing) {\n \t\tvar ctx = chartInstance.chart.ctx;\n \t\tvar chartArea = chartInstance.chartArea;\n \t\tctx.save();\n \t\tctx.beginPath();\n \t\tif (chartInstance.zoom && chartInstance.zoom.dragZoomEnd) {\n \t\t\tvar yAxis = getYAxis(chartInstance);\n \t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n \t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n \t\t\tvar endPoint = chartInstance.zoom.dragZoomEnd;\n \t\t\tvar endPointer = chartInstance.zoom.dragZoomEndPointer;\n \t\t\t\n \t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n \t\t\tvar startX = Math.min(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar endX = Math.max(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar rectWidth = endX - startX;\n \t\t\tctx.fillStyle = ''rgba(157,203,255,0.1)'';\n \t\t\tctx.lineWidth = 1;\n \t\t\tctx.strokeRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t\tctx.fillRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t}\n \t\tif (chartArea) {\n \t\t ctx.rect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);\n \t\t}\n\t\t ctx.clip(); \n },\n \n afterDatasetsDraw: function(chartInstance) {\n\t chartInstance.chart.ctx.restore();\n },\n \n destroy: function(chartInstance) {\n if (chartInstance.zoom) {\n var node = chartInstance.zoom.node;\n\t\t\t\tnode.removeEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n\t\t\t\tnode.removeEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n\t\t\t\tnode.removeEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n\t\t\t\tnode.removeEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\t \n\t\t\t\tnode.removeEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n\t\t\t\tdelete chartInstance.zoom;\n }\n }\n };\n\nChart.pluginService.register(zoomPlugin);\n","settingsSchema":"{}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"showLines\": {\n \"title\": \"Show lines\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"fillLines\": {\n \"title\": \"Fill lines\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"showPoints\": {\n \"title\": \"Show points\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"tension\": {\n \"title\": \"Line tension\",\n \"type\": \"number\",\n \"default\": 0.2\n }\n },\n \"required\": [\"showLines\", \"fillLines\", \"showPoints\"]\n },\n \"form\": [\n \"showLines\",\n \"fillLines\",\n \"showPoints\",\n \"tension\"\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.5644745944820795,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.18379294198604845,\"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\":\"Timeseries - Chart.js\"}"}', -'Timeseries - Chart.js' ); - INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'simple_gauge_justgage', '{"type":"latest","sizeX":2,"sizeY":2,"resources":[],"templateHtml":"\n","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"\nself.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#ef6c00\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":0,\"decimals\":0,\"gaugeColor\":\"#eeeeee\",\"gaugeType\":\"donut\"},\"title\":\"Simple gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Simple gauge - justGage' ); -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'bars', -'{"type":"latest","sizeX":7,"sizeY":6,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n \n for (var i in self.ctx.datasources) {\n var datasource = self.ctx.datasources[i];\n for (i in datasource.dataKeys) {\n var dataset = {\n label: datasource.dataKeys[i].label,\n data: [0],\n backgroundColor: [datasource.dataKeys[i].color],\n borderColor: [datasource.dataKeys[i].color],\n borderWidth: 1\n }\n barData.datasets.push(dataset);\n }\n }\n\n var ctx = $(''#barChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''bar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false,\n scales: {\n yAxes: [{\n ticks: {\n beginAtZero:true\n }\n }]\n }\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var c = 0;\n for (var i = 0; i < self.ctx.chart.data.datasets.length; i++) {\n var dataset = self.ctx.chart.data.datasets[i];\n var cellData = self.ctx.data[i]; \n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n dataset.data[0] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Bars - Chart.js\"}"}', -'Bars - Chart.js' ); - INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'digital_speedometer', @@ -126,7 +125,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'polar_area_chart_js', -'{"type":"latest","sizeX":7,"sizeY":6,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''polarArea'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n try {\n self.ctx.chart.resize();\n } catch (e) {}\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fifth\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.2074391823443591,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Polar Area - Chart.js\"}"}', +'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''polarArea'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n try {\n self.ctx.chart.resize();\n } catch (e) {}\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fifth\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.2074391823443591,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Polar Area - Chart.js\"}"}', 'Polar Area - Chart.js' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) @@ -152,11 +151,6 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', '{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":70,\"dashThickness\":1,\"decimals\":1,\"gaugeType\":\"arc\"},\"title\":\"Neon gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Neon gauge - justGage' ); -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie', -'{"type":"latest","sizeX":8,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.pie-label {\n font-size: 12px;\n font-family: ''Roboto'';\n font-weight: bold;\n text-align: center;\n padding: 2px;\n color: white;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''pie''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"radius\": {\n \"title\": \"Radius\",\n \"type\": \"number\",\n \"default\": 1\n },\n \"innerRadius\": {\n \"title\": \"Inner radius\",\n \"type\": \"number\",\n \"default\": 0\n },\n \"tilt\": {\n \"title\": \"Tilt\",\n \"type\": \"number\",\n \"default\": 1\n },\n \"animatedPie\": {\n \"title\": \"Enable pie animation (experimental)\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"stroke\": {\n \"title\": \"Stroke\",\n \"type\": \"object\",\n \"properties\": {\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"width\": {\n \"title\": \"Width (pixels)\",\n \"type\": \"number\",\n \"default\": 0\n }\n }\n },\n \"showLabels\": {\n \"title\": \"Show labels\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"fontColor\": {\n \"title\": \"Font color\",\n \"type\": \"string\",\n \"default\": \"#545454\"\n },\n \"fontSize\": {\n \"title\": \"Font size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"decimals\": {\n \"title\": \"Number of digits after floating point\",\n \"type\": \"number\",\n \"default\": 1\n },\n \"units\": {\n \"title\": \"Special symbol to show next to value\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"legend\": {\n \"title\": \"Legend settings\",\n \"type\": \"object\",\n \"properties\": {\n \"show\": {\n \"title\": \"Show legend\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"position\": {\n \"title\": \"Position\",\n \"type\": \"string\",\n \"default\": \"nw\"\n },\n \"labelBoxBorderColor\": {\n \"title\": \"Label box border color\",\n \"type\": \"string\",\n \"default\": \"#CCCCCC\"\n },\n \"backgroundColor\": {\n \"title\": \"Background color\",\n \"type\": \"string\",\n \"default\": \"#F0F0F0\"\n },\n \"backgroundOpacity\": {\n \"title\": \"Background opacity\",\n \"type\": \"number\",\n \"default\": 0.85\n }\n }\n }\n },\n \"required\": []\n },\n \"form\": [\n \"radius\", \n \"innerRadius\",\n \"animatedPie\",\n \"tilt\",\n {\n \"key\": \"stroke\",\n \"items\": [\n {\n \"key\": \"stroke.color\",\n \"type\": \"color\"\n },\n \"stroke.width\"\n ]\n },\n \"showLabels\",\n {\n \"key\": \"fontColor\",\n \"type\": \"color\"\n },\n \"fontSize\", \n \"decimals\",\n \"units\",\n {\n \"key\": \"legend\",\n \"items\": [\n \"legend.show\",\n {\n \"key\": \"legend.position\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"nw\",\n \"label\": \"North-west\"\n },\n {\n \"value\": \"ne\",\n \"label\": \"North-east\"\n },\n {\n \"value\": \"sw\",\n \"label\": \"South-west\"\n },\n {\n \"value\": \"se\",\n \"label\": \"Soth-east\"\n }\n ]\n }, \n {\n \"key\": \"legend.labelBoxBorderColor\",\n \"type\": \"color\"\n }, \n {\n \"key\": \"legend.backgroundColor\",\n \"type\": \"color\"\n },\n \"legend.backgroundOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.6114638304362894,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.9955906536344441,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.9430835931647599,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"radius\":1,\"fontColor\":\"#545454\",\"fontSize\":10,\"decimals\":1,\"legend\":{\"show\":true,\"position\":\"nw\",\"labelBoxBorderColor\":\"#CCCCCC\",\"backgroundColor\":\"#F0F0F0\",\"backgroundOpacity\":0.85},\"innerRadius\":0,\"showLabels\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Pie - Flot' ); - INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map_openstreetmap', '{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''openstreet-map'', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n ]\n },\n \"form\": [\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].useColorFunction\",\n {\n \"key\": \"routesSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"routesSettings[].markerImageSize\",\n \"routesSettings[].useMarkerImageFunction\",\n {\n \"key\": \"routesSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"routesSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8950926999078694,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.2757675428823283,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.14481354591724638,\"funcBody\":\"var value = prevValue;\\nif (time % 500 < 100) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"strokeWeight\":4,\"label\":\"First route\",\"color\":\"#3d5afe\",\"strokeOpacity\":1,\"useColorFunction\":true,\"useMarkerImageFunction\":true,\"markerImages\":[\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"colorFunction\":\"var speed = data[''Speed''];\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix(''green'', ''yellow'', amount = percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix(''yellow'', ''red'', amount = percent).toHexString();\\n }\\n}\\nreturn ''green'';\",\"markerImageFunction\":\"var speed = data[''Speed''];\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.floor(3 * percent);\\n res.url = images[index];\\n}\\nreturn res;\"}]},\"title\":\"Route Map - OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', @@ -217,11 +211,6 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'simple_card', '{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n\n.tbDatasource-table {\n width: 100%;\n height: 100%;\n border-collapse: collapse;\n white-space: nowrap;\n font-weight: 100;\n text-align: right;\n}\n\n.tbDatasource-table td {\n padding: 12px;\n position: relative;\n box-sizing: border-box;\n}\n\n.tbDatasource-data-key {\n opacity: 0.7;\n font-weight: 400;\n font-size: 3.500rem;\n}\n\n.tbDatasource-value {\n font-size: 5.000rem;\n}","controllerScript":"self.onInit = function() {\n self.ctx.units = self.ctx.settings.units || \"\";\n self.ctx.valueDec = (typeof self.ctx.settings.valueDec !== ''undefined'' && self.ctx.settings.valueDec !== null)\n ? self.ctx.settings.valueDec : 2;\n \n self.ctx.labelPosition = self.ctx.settings.labelPosition || ''left'';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = ''tbDatasource'' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n \n var tableId = ''table'' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $(''#'' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === ''top'') {\n table.css(''text-align'', ''left'');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = ''labelCell'' + 0;\n var cellId = ''cell'' + 0;\n if (self.ctx.labelPosition === ''left'') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $(''#'' + labelCellId, table);\n self.ctx.valueCell = $(''#'' + cellId, table);\n self.ctx.valueCell.html(0 + '' '' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '''' + html_org + '''';\n $(this).html(html_calc);\n var width = $(this).find(''span:first'').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n \n function padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n \n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n \n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n \n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n \n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n \n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n \n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n \n strVal = (n ? ''-'' : '''') + strVal;\n }\n \n return strVal;\n }\n \n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n txtValue = padValue(value, self.ctx.alueDec, 0) + '' '' + self.ctx.units;\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === ''left'') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css(''font-size'', fontSize+''px'');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n}\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === ''top'') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css(''font-size'', labelFontSize+''px'');\n self.ctx.labelCell.css(''padding'', self.ctx.padding+''px'');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css(''font-size'', self.ctx.valueFontSize+''px'');\n self.ctx.valueCell.css(''padding'', self.ctx.padding+''px'');\n } \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"units\": {\n \"title\": \"Units\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"valueDec\": {\n \"title\": \"Digits count for decimal part of value\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"labelPosition\": {\n \"title\": \"Label position\",\n \"type\": \"string\",\n \"default\": \"left\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"units\",\n \"valueDec\",\n {\n \"key\": \"labelPosition\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"left\",\n \"label\": \"Left\"\n },\n {\n \"value\": \"top\",\n \"label\": \"Top\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ff5722\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"16px\",\"settings\":{\"units\":\"°C\",\"valueDec\":1,\"labelPosition\":\"top\"},\"title\":\"Simple card\"}"}', 'Simple card' ); -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'basic_timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"shadowSize\": {\n \"title\": \"Shadow size\",\n \"type\": \"number\",\n \"default\": 4\n },\n \"fontColor\": {\n \"title\": \"Font color\",\n \"type\": \"string\",\n \"default\": \"#545454\"\n },\n \"fontSize\": {\n \"title\": \"Font size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"decimals\": {\n \"title\": \"Number of digits after floating point\",\n \"type\": \"number\",\n \"default\": 1\n },\n \"units\": {\n \"title\": \"Special symbol to show next to value\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"grid\": {\n \"title\": \"Grid settings\",\n \"type\": \"object\",\n \"properties\": {\n \"color\": {\n \"title\": \"Primary color\",\n \"type\": \"string\",\n \"default\": \"#545454\"\n },\n \"backgroundColor\": {\n \"title\": \"Background color\",\n \"type\": \"string\",\n \"default\": null\n },\n \"tickColor\": {\n \"title\": \"Ticks color\",\n \"type\": \"string\",\n \"default\": \"#DDDDDD\"\n },\n \"outlineWidth\": {\n \"title\": \"Grid outline/border width (px)\",\n \"type\": \"number\",\n \"default\": 1\n },\n \"verticalLines\": {\n \"title\": \"Show vertical lines\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"horizontalLines\": {\n \"title\": \"Show horizontal lines\",\n \"type\": \"boolean\",\n \"default\": true\n }\n }\n },\n \"legend\": {\n \"title\": \"Legend settings\",\n \"type\": \"object\",\n \"properties\": {\n \"show\": {\n \"title\": \"Show legend\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"position\": {\n \"title\": \"Position\",\n \"type\": \"string\",\n \"default\": \"nw\"\n },\n \"labelBoxBorderColor\": {\n \"title\": \"Label box border color\",\n \"type\": \"string\",\n \"default\": \"#CCCCCC\"\n },\n \"backgroundColor\": {\n \"title\": \"Background color\",\n \"type\": \"string\",\n \"default\": \"#F0F0F0\"\n },\n \"backgroundOpacity\": {\n \"title\": \"Background opacity\",\n \"type\": \"number\",\n \"default\": 0.85\n }\n }\n },\n \"xaxis\": {\n \"title\": \"X axis settings\",\n \"type\": \"object\",\n \"properties\": {\n \"showLabels\": {\n \"title\": \"Show labels\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"title\": {\n \"title\": \"Axis title\",\n \"type\": \"string\",\n \"default\": null\n },\n \"titleAngle\": {\n \"title\": \"Axis title''s angle in degrees\",\n \"type\": \"number\",\n \"default\": 0\n },\n \"color\": {\n \"title\": \"Ticks color\",\n \"type\": \"string\",\n \"default\": null\n }\n }\n },\n \"yaxis\": {\n \"title\": \"Y axis settings\",\n \"type\": \"object\",\n \"properties\": {\n \"showLabels\": {\n \"title\": \"Show labels\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"title\": {\n \"title\": \"Axis title\",\n \"type\": \"string\",\n \"default\": null\n },\n \"titleAngle\": {\n \"title\": \"Axis title''s angle in degrees\",\n \"type\": \"number\",\n \"default\": 0\n },\n \"color\": {\n \"title\": \"Ticks color\",\n \"type\": \"string\",\n \"default\": null\n }\n }\n }\n },\n \"required\": []\n },\n \"form\": [\n \"shadowSize\", \n {\n \"key\": \"fontColor\",\n \"type\": \"color\"\n },\n \"fontSize\", \n \"decimals\",\n \"units\",\n {\n \"key\": \"grid\",\n \"items\": [\n {\n \"key\": \"grid.color\",\n \"type\": \"color\"\n },\n {\n \"key\": \"grid.backgroundColor\",\n \"type\": \"color\"\n },\n {\n \"key\": \"grid.tickColor\",\n \"type\": \"color\"\n },\n \"grid.outlineWidth\",\n \"grid.verticalLines\",\n \"grid.horizontalLines\"\n ]\n }, \n {\n \"key\": \"legend\",\n \"items\": [\n \"legend.show\",\n {\n \"key\": \"legend.position\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"nw\",\n \"label\": \"North-west\"\n },\n {\n \"value\": \"ne\",\n \"label\": \"North-east\"\n },\n {\n \"value\": \"sw\",\n \"label\": \"South-west\"\n },\n {\n \"value\": \"se\",\n \"label\": \"Soth-east\"\n }\n ]\n }, \n {\n \"key\": \"legend.labelBoxBorderColor\",\n \"type\": \"color\"\n }, \n {\n \"key\": \"legend.backgroundColor\",\n \"type\": \"color\"\n },\n \"legend.backgroundOpacity\"\n ]\n },\n {\n \"key\": \"xaxis\",\n \"items\": [\n \"xaxis.showLabels\",\n \"xaxis.title\",\n \"xaxis.titleAngle\",\n {\n \"key\": \"xaxis.color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"yaxis\",\n \"items\": [\n \"yaxis.showLabels\",\n \"yaxis.title\",\n \"yaxis.titleAngle\",\n {\n \"key\": \"yaxis.color\",\n \"type\": \"color\"\n }\n ]\n }\n\n ]\n}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"showLines\": {\n \"title\": \"Show lines\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"fillLines\": {\n \"title\": \"Fill lines\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"showPoints\": {\n \"title\": \"Show points\",\n \"type\": \"boolean\",\n \"default\": false\n }\n },\n \"required\": [\"showLines\", \"fillLines\", \"showPoints\"]\n },\n \"form\": [\n \"showLines\",\n \"fillLines\",\n \"showPoints\"\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', -'Timeseries - Flot' ); - INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'raspberry_pi_gpio_control', @@ -241,7 +230,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'timeseries_table', -'{"type":"timeseries","sizeX":8,"sizeY":6.5,"resources":[],"templateHtml":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Timestamp{{ h.label }}
0 || ($index === 0 && showTimestamp)\" md-cell ng-repeat=\"d in row track by $index\" ng-style=\"cellStyle(source, $index, d)\" ng-bind-html=\"cellContent(source, $index, row, d)\">\n
\n
\n \n \n
\n
","templateCss":"table.md-table thead.md-head>tr.md-row {\n height: 40px;\n}\n\ntable.md-table tbody.md-body>tr.md-row, table.md-table tfoot.md-foot>tr.md-row {\n height: 38px;\n}\n\n.md-table-pagination>* {\n height: 46px;\n}\n","controllerScript":"self.onInit = function() {\n \n var scope = self.ctx.$scope;\n \n self.ctx.filter = scope.$injector.get(\"$filter\");\n\n scope.sources = [];\n scope.sourceIndex = 0;\n scope.showTimestamp = self.ctx.settings.showTimestamp !== false;\n \n var keyOffset = 0;\n for (var ds in self.ctx.datasources) {\n var source = {};\n var datasource = self.ctx.datasources[ds];\n source.keyStartIndex = keyOffset;\n keyOffset += datasource.dataKeys.length;\n source.keyEndIndex = keyOffset;\n source.label = datasource.name;\n source.data = [];\n source.rawData = [];\n source.query = {\n limit: 5,\n page: 1,\n order: ''-0''\n }\n source.ts = {\n header: [],\n count: 0,\n data: [],\n stylesInfo: [],\n contentsInfo: [],\n rowDataTemplate: {}\n }\n source.ts.rowDataTemplate[''Timestamp''] = null;\n for (var a = 0; a < datasource.dataKeys.length; a++ ) {\n var dataKey = datasource.dataKeys[a];\n var keySettings = dataKey.settings;\n source.ts.header.push({\n index: a+1,\n label: dataKey.label\n });\n source.ts.rowDataTemplate[dataKey.label] = null;\n\n var cellStyleFunction = null;\n var useCellStyleFunction = false;\n \n if (keySettings.useCellStyleFunction === true) {\n if (angular.isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) {\n try {\n cellStyleFunction = new Function(''value'', keySettings.cellStyleFunction);\n useCellStyleFunction = true;\n } catch (e) {\n cellStyleFunction = null;\n useCellStyleFunction = false;\n }\n }\n }\n\n source.ts.stylesInfo.push({\n useCellStyleFunction: useCellStyleFunction,\n cellStyleFunction: cellStyleFunction\n });\n \n var cellContentFunction = null;\n var useCellContentFunction = false;\n \n if (keySettings.useCellContentFunction === true) {\n if (angular.isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) {\n try {\n cellContentFunction = new Function(''value, rowData, filter'', keySettings.cellContentFunction);\n useCellContentFunction = true;\n } catch (e) {\n cellContentFunction = null;\n useCellContentFunction = false;\n }\n }\n }\n \n source.ts.contentsInfo.push({\n useCellContentFunction: useCellContentFunction,\n cellContentFunction: cellContentFunction\n });\n \n }\n scope.sources.push(source);\n }\n\n scope.onPaginate = function(source) {\n updatePage(source);\n }\n \n scope.onReorder = function(source) {\n reorder(source);\n updatePage(source);\n }\n \n scope.cellStyle = function(source, index, value) {\n var style = {};\n if (index > 0) {\n var styleInfo = source.ts.stylesInfo[index-1];\n if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {\n try {\n style = styleInfo.cellStyleFunction(value);\n } catch (e) {\n style = {};\n }\n }\n }\n return style;\n }\n\n scope.cellContent = function(source, index, row, value) {\n if (index === 0) {\n return self.ctx.filter(''date'')(value, ''yyyy-MM-dd HH:mm:ss'');\n } else {\n var strContent = '''';\n if (angular.isDefined(value)) {\n strContent = ''''+value;\n }\n var content = strContent;\n var contentInfo = source.ts.contentsInfo[index-1];\n if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {\n try {\n var rowData = source.ts.rowDataTemplate;\n rowData[''Timestamp''] = row[0];\n for (var h in source.ts.header) {\n var headerInfo = source.ts.header[h];\n rowData[headerInfo.label] = row[headerInfo.index];\n }\n content = contentInfo.cellContentFunction(value, rowData, filter);\n } catch (e) {\n content = strContent;\n }\n } \n return content;\n }\n }\n \n scope.$watch(''sourceIndex'', function(newIndex, oldIndex) {\n if (newIndex != oldIndex) {\n updateSourceData(scope.sources[scope.sourceIndex]);\n } \n });\n}\n\nself.onDataUpdated = function() {\n var scope = self.ctx.$scope;\n for (var s in scope.sources) {\n var source = scope.sources[s];\n source.rawData = self.ctx.data.slice(source.keyStartIndex, source.keyEndIndex);\n }\n updateSourceData(scope.sources[scope.sourceIndex]);\n scope.$digest();\n}\n\nself.onDestroy = function() {\n}\n\nfunction updatePage(source) {\n var startIndex = source.query.limit * (source.query.page - 1);\n source.ts.data = source.data.slice(startIndex, startIndex + source.query.limit);\n}\n\nfunction reorder(source) {\n source.data = self.ctx.filter(''orderBy'')(source.data, source.query.order);\n}\n\nfunction convertData(data) {\n var rowsMap = [];\n for (var d = 0; d < data.length; d++) {\n var columnData = data[d].data;\n for (var i = 0; i < columnData.length; i++) {\n var cellData = columnData[i];\n var timestamp = cellData[0];\n var row = rowsMap[timestamp];\n if (!row) {\n row = [];\n row[0] = timestamp;\n for (var c = 0; c < data.length; c++) {\n row[c+1] = undefined;\n }\n rowsMap[timestamp] = row;\n }\n row[d+1] = cellData[1];\n }\n }\n var rows = [];\n for (var t in rowsMap) {\n rows.push(rowsMap[t]);\n }\n return rows;\n}\n\nfunction updateSourceData(source) {\n source.data = convertData(source.rawData);\n source.ts.count = source.data.length;\n reorder(source);\n updatePage(source);\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\"\n ]\n}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix(''blue'', ''red'', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: ''20px'',\\n color: ''#ffffff'',\\n background: color.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor(''blue'');\\n backgroundColor.setAlpha(value/100);\\n var color = ''blue'';\\n if (value > 50) {\\n color = ''white'';\\n }\\n \\n return {\\n paddingLeft: ''20px'',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true},\"title\":\"Timeseries table\"}"}', +'{"type":"timeseries","sizeX":8,"sizeY":6.5,"resources":[],"templateHtml":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Timestamp{{ h.label }}
0 || ($index === 0 && showTimestamp)\" md-cell ng-repeat=\"d in row track by $index\" ng-style=\"cellStyle(source, $index, d)\" ng-bind-html=\"cellContent(source, $index, row, d)\">\n
\n
\n \n \n
\n
","templateCss":"table.md-table thead.md-head>tr.md-row {\n height: 40px;\n}\n\ntable.md-table tbody.md-body>tr.md-row, table.md-table tfoot.md-foot>tr.md-row {\n height: 38px;\n}\n\n.md-table-pagination>* {\n height: 46px;\n}\n","controllerScript":"self.onInit = function() {\n \n var scope = self.ctx.$scope;\n \n self.ctx.filter = scope.$injector.get(\"$filter\");\n\n scope.sources = [];\n scope.sourceIndex = 0;\n scope.showTimestamp = self.ctx.settings.showTimestamp !== false;\n \n var keyOffset = 0;\n for (var ds in self.ctx.datasources) {\n var source = {};\n var datasource = self.ctx.datasources[ds];\n source.keyStartIndex = keyOffset;\n keyOffset += datasource.dataKeys.length;\n source.keyEndIndex = keyOffset;\n source.label = datasource.name;\n source.data = [];\n source.rawData = [];\n source.query = {\n limit: 5,\n page: 1,\n order: ''-0''\n }\n source.ts = {\n header: [],\n count: 0,\n data: [],\n stylesInfo: [],\n contentsInfo: [],\n rowDataTemplate: {}\n }\n source.ts.rowDataTemplate[''Timestamp''] = null;\n for (var a = 0; a < datasource.dataKeys.length; a++ ) {\n var dataKey = datasource.dataKeys[a];\n var keySettings = dataKey.settings;\n source.ts.header.push({\n index: a+1,\n label: dataKey.label\n });\n source.ts.rowDataTemplate[dataKey.label] = null;\n\n var cellStyleFunction = null;\n var useCellStyleFunction = false;\n \n if (keySettings.useCellStyleFunction === true) {\n if (angular.isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) {\n try {\n cellStyleFunction = new Function(''value'', keySettings.cellStyleFunction);\n useCellStyleFunction = true;\n } catch (e) {\n cellStyleFunction = null;\n useCellStyleFunction = false;\n }\n }\n }\n\n source.ts.stylesInfo.push({\n useCellStyleFunction: useCellStyleFunction,\n cellStyleFunction: cellStyleFunction\n });\n \n var cellContentFunction = null;\n var useCellContentFunction = false;\n \n if (keySettings.useCellContentFunction === true) {\n if (angular.isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) {\n try {\n cellContentFunction = new Function(''value, rowData, filter'', keySettings.cellContentFunction);\n useCellContentFunction = true;\n } catch (e) {\n cellContentFunction = null;\n useCellContentFunction = false;\n }\n }\n }\n \n source.ts.contentsInfo.push({\n useCellContentFunction: useCellContentFunction,\n cellContentFunction: cellContentFunction\n });\n \n }\n scope.sources.push(source);\n }\n\n scope.onPaginate = function(source) {\n updatePage(source);\n }\n \n scope.onReorder = function(source) {\n reorder(source);\n updatePage(source);\n }\n \n scope.cellStyle = function(source, index, value) {\n var style = {};\n if (index > 0) {\n var styleInfo = source.ts.stylesInfo[index-1];\n if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {\n try {\n style = styleInfo.cellStyleFunction(value);\n } catch (e) {\n style = {};\n }\n }\n }\n return style;\n }\n\n scope.cellContent = function(source, index, row, value) {\n if (index === 0) {\n return self.ctx.filter(''date'')(value, ''yyyy-MM-dd HH:mm:ss'');\n } else {\n var strContent = '''';\n if (angular.isDefined(value)) {\n strContent = ''''+value;\n }\n var content = strContent;\n var contentInfo = source.ts.contentsInfo[index-1];\n if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {\n try {\n var rowData = source.ts.rowDataTemplate;\n rowData[''Timestamp''] = row[0];\n for (var h in source.ts.header) {\n var headerInfo = source.ts.header[h];\n rowData[headerInfo.label] = row[headerInfo.index];\n }\n content = contentInfo.cellContentFunction(value, rowData, filter);\n } catch (e) {\n content = strContent;\n }\n } \n return content;\n }\n }\n \n scope.$watch(''sourceIndex'', function(newIndex, oldIndex) {\n if (newIndex != oldIndex) {\n updateSourceData(scope.sources[scope.sourceIndex]);\n } \n });\n}\n\nself.onDataUpdated = function() {\n var scope = self.ctx.$scope;\n for (var s in scope.sources) {\n var source = scope.sources[s];\n source.rawData = self.ctx.data.slice(source.keyStartIndex, source.keyEndIndex);\n }\n updateSourceData(scope.sources[scope.sourceIndex]);\n scope.$digest();\n}\n\nself.onDestroy = function() {\n}\n\nfunction updatePage(source) {\n var startIndex = source.query.limit * (source.query.page - 1);\n source.ts.data = source.data.slice(startIndex, startIndex + source.query.limit);\n}\n\nfunction reorder(source) {\n source.data = self.ctx.filter(''orderBy'')(source.data, source.query.order);\n}\n\nfunction convertData(data) {\n var rowsMap = [];\n for (var d = 0; d < data.length; d++) {\n var columnData = data[d].data;\n for (var i = 0; i < columnData.length; i++) {\n var cellData = columnData[i];\n var timestamp = cellData[0];\n var row = rowsMap[timestamp];\n if (!row) {\n row = [];\n row[0] = timestamp;\n for (var c = 0; c < data.length; c++) {\n row[c+1] = undefined;\n }\n rowsMap[timestamp] = row;\n }\n row[d+1] = cellData[1];\n }\n }\n var rows = [];\n for (var t in rowsMap) {\n rows.push(rowsMap[t]);\n }\n return rows;\n}\n\nfunction updateSourceData(source) {\n source.data = convertData(source.rawData);\n source.ts.count = source.data.length;\n reorder(source);\n updatePage(source);\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\"\n ]\n}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix(''blue'', ''red'', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: ''20px'',\\n color: ''#ffffff'',\\n background: color.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor(''blue'');\\n backgroundColor.setAlpha(value/100);\\n var color = ''blue'';\\n if (value > 50) {\\n color = ''white'';\\n }\\n \\n return {\\n paddingLeft: ''20px'',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"NONE\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Timeseries table' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) @@ -252,7 +241,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie_chart_js', -'{"type":"latest","sizeX":8,"sizeY":6,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''pie'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n }); \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Pie - Chart.js\"}"}', +'{"type":"latest","sizeX":8,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''pie'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n }); \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Pie - Chart.js\"}"}', 'Pie - Chart.js' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) @@ -268,7 +257,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'doughnut_chart_js', -'{"type":"latest","sizeX":7,"sizeY":6,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n var borderColor = self.ctx.settings.borderColor || ''#fff'';\n var borderWidth = angular.isDefined(self.ctx.settings.borderWidth) ? self.ctx.settings.borderWidth : 5;\n \n pieData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(borderColor);\n dataset.borderWidth.push(borderWidth);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var options = {\n responsive: false,\n maintainAspectRatio: false,\n legend: {\n display: true,\n labels: {\n fontColor: ''#666''\n }\n },\n tooltips: {\n callbacks: {\n label: function(tooltipItem, data) {\n var label = data.labels[tooltipItem.index];\n var value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];\n var content = label + '': '' + value;\n if (self.ctx.settings.units) {\n content += '' '' + self.ctx.settings.units;\n } \n return content;\n }\n }\n }\n };\n\n if (self.ctx.settings.legend) {\n options.legend.display = self.ctx.settings.legend.display !== false;\n options.legend.labels.fontColor = self.ctx.settings.legend.labelsFontColor || ''#666'';\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''doughnut'',\n data: pieData,\n options: options\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"borderWidth\": {\n \"title\": \"Border width\",\n \"type\": \"number\",\n \"default\": 5\n },\n \"borderColor\": {\n \"title\": \"Border color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n },\n \"units\": {\n \"title\": \"Special symbol to show next to value\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"legend\": {\n \"title\": \"Legend settings\",\n \"type\": \"object\",\n \"properties\": {\n \"display\": {\n \"title\": \"Display legend\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"labelsFontColor\": {\n \"title\": \"Labels font color\",\n \"type\": \"string\",\n \"default\": \"#666\"\n }\n }\n }\n },\n \"required\": []\n },\n \"form\": [\n \"borderWidth\", \n {\n \"key\": \"borderColor\",\n \"type\": \"color\"\n }, \n \"units\",\n {\n \"key\": \"legend\",\n \"items\": [\n \"legend.display\",\n {\n \"key\": \"legend.labelsFontColor\",\n \"type\": \"color\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#26a69a\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#f57c00\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#afb42b\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#673ab7\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"borderWidth\":5,\"borderColor\":\"#fff\",\"legend\":{\"display\":true,\"labelsFontColor\":\"#666666\"}},\"title\":\"Doughnut - Chart.js\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', +'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n var borderColor = self.ctx.settings.borderColor || ''#fff'';\n var borderWidth = angular.isDefined(self.ctx.settings.borderWidth) ? self.ctx.settings.borderWidth : 5;\n \n pieData.datasets.push(dataset);\n \n for (var i in self.ctx.data) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(borderColor);\n dataset.borderWidth.push(borderWidth);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var options = {\n responsive: false,\n maintainAspectRatio: false,\n legend: {\n display: true,\n labels: {\n fontColor: ''#666''\n }\n },\n tooltips: {\n callbacks: {\n label: function(tooltipItem, data) {\n var label = data.labels[tooltipItem.index];\n var value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];\n var content = label + '': '' + value;\n if (self.ctx.settings.units) {\n content += '' '' + self.ctx.settings.units;\n } \n return content;\n }\n }\n }\n };\n\n if (self.ctx.settings.legend) {\n options.legend.display = self.ctx.settings.legend.display !== false;\n options.legend.labels.fontColor = self.ctx.settings.legend.labelsFontColor || ''#666'';\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''doughnut'',\n data: pieData,\n options: options\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"borderWidth\": {\n \"title\": \"Border width\",\n \"type\": \"number\",\n \"default\": 5\n },\n \"borderColor\": {\n \"title\": \"Border color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n },\n \"units\": {\n \"title\": \"Special symbol to show next to value\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"legend\": {\n \"title\": \"Legend settings\",\n \"type\": \"object\",\n \"properties\": {\n \"display\": {\n \"title\": \"Display legend\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"labelsFontColor\": {\n \"title\": \"Labels font color\",\n \"type\": \"string\",\n \"default\": \"#666\"\n }\n }\n }\n },\n \"required\": []\n },\n \"form\": [\n \"borderWidth\", \n {\n \"key\": \"borderColor\",\n \"type\": \"color\"\n }, \n \"units\",\n {\n \"key\": \"legend\",\n \"items\": [\n \"legend.display\",\n {\n \"key\": \"legend.labelsFontColor\",\n \"type\": \"color\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#26a69a\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#f57c00\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#afb42b\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#673ab7\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"borderWidth\":5,\"borderColor\":\"#fff\",\"legend\":{\"display\":true,\"labelsFontColor\":\"#666666\"}},\"title\":\"Doughnut - Chart.js\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Doughnut - Chart.js' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) @@ -281,6 +270,20 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map', '{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''google-map'', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all routes\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].useColorFunction\",\n {\n \"key\": \"routesSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"routesSettings[].markerImageSize\",\n \"routesSettings[].useMarkerImageFunction\",\n {\n \"key\": \"routesSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"routesSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.3467277073670627,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.058309787276281666,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.14288960550237473,\"funcBody\":\"var value = prevValue;\\nif (time % 500 < 100) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"color\":\"#1976d2\",\"strokeWeight\":4,\"strokeOpacity\":0.65,\"label\":\"First route\",\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"useColorFunction\":true,\"markerImageSize\":34,\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var speed = data[''Speed''];\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.floor(3 * percent);\\n res.url = images[index];\\n}\\nreturn res;\",\"markerImages\":[\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"colorFunction\":\"var speed = data[''Speed''];\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix(''green'', ''yellow'', amount = percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix(''yellow'', ''red'', amount = percent).toHexString();\\n }\\n}\\nreturn ''green'';\"}]},\"title\":\"Route Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Route Map' ); +INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) +VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie', +'{"type":"latest","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.pie-label {\n font-size: 12px;\n font-family: ''Roboto'';\n font-weight: bold;\n text-align: center;\n padding: 2px;\n color: white;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''pie''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.pieSettingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.pieDatakeySettingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}\n","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.6114638304362894,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.9955906536344441,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.9430835931647599,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"radius\":1,\"fontColor\":\"#545454\",\"fontSize\":10,\"decimals\":1,\"legend\":{\"show\":true,\"position\":\"nw\",\"labelBoxBorderColor\":\"#CCCCCC\",\"backgroundColor\":\"#F0F0F0\",\"backgroundOpacity\":0.85},\"innerRadius\":0,\"showLabels\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', +'Pie - Flot' ); + +INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) +VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries_bars_flot', +'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''bar''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', +'Timeseries Bars - Flot' ); + +INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) +VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'basic_timeseries', +'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', +'Timeseries - Flot' ); /** System plugins and rules **/ INSERT INTO thingsboard.plugin ( id, tenant_id, name, state, search_text, api_token, plugin_class, public_access, From 087cd95bb50fd3c25408645caba7c0ef4b539443 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 28 Feb 2017 19:03:44 +0200 Subject: [PATCH 11/14] UI: Improve aggregation interval configuration. Minor bug fixes. --- .../server/common/data/kv/BaseTsKvQuery.java | 6 +- .../server/common/data/kv/TsKvQuery.java | 2 + .../dao/timeseries/BaseTimeseriesDao.java | 4 +- dao/src/main/resources/system-data.cql | 6 +- .../dao/timeseries/TimeseriesServiceTest.java | 12 +- .../plugin/telemetry/cmd/GetHistoryCmd.java | 1 + .../cmd/TimeseriesSubscriptionCmd.java | 1 + .../handlers/TelemetryRestMsgHandler.java | 3 +- .../TelemetryWebsocketMsgHandler.java | 4 +- ui/src/app/api/data-aggregator.js | 57 ++- ui/src/app/api/datasource.service.js | 91 +++-- ui/src/app/api/time.service.js | 330 ++++++++++++++++++ ui/src/app/api/widget.service.js | 4 +- ui/src/app/app.js | 2 + .../app/components/timeinterval.directive.js | 131 ++++--- ui/src/app/components/timeinterval.scss | 10 +- ui/src/app/components/timeinterval.tpl.html | 66 ++-- .../components/timewindow-panel.controller.js | 50 ++- .../app/components/timewindow-panel.tpl.html | 115 +++--- ui/src/app/components/timewindow.directive.js | 50 +-- ui/src/app/components/timewindow.scss | 27 +- ui/src/app/components/timewindow.tpl.html | 4 +- ui/src/app/components/widget.controller.js | 87 ++--- .../attribute/attribute-table.directive.js | 5 +- ui/src/app/locale/locale.constant.js | 4 +- ui/src/app/widget/lib/flot-widget.js | 53 ++- ui/src/scss/main.scss | 8 + 27 files changed, 796 insertions(+), 337 deletions(-) create mode 100644 ui/src/app/api/time.service.js diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java index ed48206340..e95496b48b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java @@ -23,19 +23,21 @@ public class BaseTsKvQuery implements TsKvQuery { private final String key; private final long startTs; private final long endTs; + private final long interval; private final int limit; private final Aggregation aggregation; - public BaseTsKvQuery(String key, long startTs, long endTs, int limit, Aggregation aggregation) { + public BaseTsKvQuery(String key, long startTs, long endTs, long interval, int limit, Aggregation aggregation) { this.key = key; this.startTs = startTs; this.endTs = endTs; + this.interval = interval; this.limit = limit; this.aggregation = aggregation; } public BaseTsKvQuery(String key, long startTs, long endTs) { - this(key, startTs, endTs, 1, Aggregation.AVG); + this(key, startTs, endTs, endTs-startTs, 1, Aggregation.AVG); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java index 10a13ce797..8d60f525f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java @@ -25,6 +25,8 @@ public interface TsKvQuery { long getEndTs(); + long getInterval(); + int getLimit(); Aggregation getAggregation(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java index 10651cbad0..c7584fe511 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesDao.java @@ -112,13 +112,13 @@ public class BaseTimeseriesDao extends AbstractAsyncDao implements TimeseriesDao if (query.getAggregation() == Aggregation.NONE) { return findAllAsyncWithLimit(entityType, entityId, query); } else { - long step = Math.max((query.getEndTs() - query.getStartTs()) / query.getLimit(), minAggregationStepMs); + long step = Math.max(query.getInterval(), minAggregationStepMs); long stepTs = query.getStartTs(); List>> futures = new ArrayList<>(); while (stepTs < query.getEndTs()) { long startTs = stepTs; long endTs = stepTs + step; - TsKvQuery subQuery = new BaseTsKvQuery(query.getKey(), startTs, endTs, 1, query.getAggregation()); + TsKvQuery subQuery = new BaseTsKvQuery(query.getKey(), startTs, endTs, step, 1, query.getAggregation()); futures.add(findAndAggregateAsync(entityType, entityId, subQuery, toPartitionTs(startTs), toPartitionTs(endTs))); stepTs = endTs; } diff --git a/dao/src/main/resources/system-data.cql b/dao/src/main/resources/system-data.cql index e7ccc56d1b..cb274a5612 100644 --- a/dao/src/main/resources/system-data.cql +++ b/dao/src/main/resources/system-data.cql @@ -272,17 +272,17 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map', INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie', -'{"type":"latest","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.pie-label {\n font-size: 12px;\n font-family: ''Roboto'';\n font-weight: bold;\n text-align: center;\n padding: 2px;\n color: white;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''pie''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.pieSettingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.pieDatakeySettingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}\n","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.6114638304362894,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.9955906536344441,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.9430835931647599,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"radius\":1,\"fontColor\":\"#545454\",\"fontSize\":10,\"decimals\":1,\"legend\":{\"show\":true,\"position\":\"nw\",\"labelBoxBorderColor\":\"#CCCCCC\",\"backgroundColor\":\"#F0F0F0\",\"backgroundOpacity\":0.85},\"innerRadius\":0,\"showLabels\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', +'{"type":"latest","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.pie-label {\n font-size: 12px;\n font-family: ''Roboto'';\n font-weight: bold;\n text-align: center;\n padding: 2px;\n color: white;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''pie''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.pieSettingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.pieDatakeySettingsSchema;\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}\n","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.6114638304362894,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.9955906536344441,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.9430835931647599,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"radius\":1,\"fontColor\":\"#545454\",\"fontSize\":10,\"decimals\":1,\"legend\":{\"show\":true,\"position\":\"nw\",\"labelBoxBorderColor\":\"#CCCCCC\",\"backgroundColor\":\"#F0F0F0\",\"backgroundOpacity\":0.85},\"innerRadius\":0,\"showLabels\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', 'Pie - Flot' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries_bars_flot', -'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''bar''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', +'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''bar''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', 'Timeseries Bars - Flot' ); INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'basic_timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', +'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[],"templateHtml":"","templateCss":".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', 'Timeseries - Flot' ); /** System plugins and rules **/ diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java index fd16b75f95..134f4710c7 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/TimeseriesServiceTest.java @@ -115,7 +115,7 @@ public class TimeseriesServiceTest extends AbstractServiceTest { entries.add(save(deviceId, 55000, 600)); List list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.NONE))).get(); + 60000, 20000, 3, Aggregation.NONE))).get(); assertEquals(3, list.size()); assertEquals(55000, list.get(0).getTs()); assertEquals(java.util.Optional.of(600L), list.get(0).getLongValue()); @@ -127,7 +127,7 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(400L), list.get(2).getLongValue()); list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.AVG))).get(); + 60000, 20000, 3, Aggregation.AVG))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); assertEquals(java.util.Optional.of(150L), list.get(0).getLongValue()); @@ -139,7 +139,7 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(550L), list.get(2).getLongValue()); list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.SUM))).get(); + 60000, 20000, 3, Aggregation.SUM))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); @@ -152,7 +152,7 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(1100L), list.get(2).getLongValue()); list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.MIN))).get(); + 60000, 20000, 3, Aggregation.MIN))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); @@ -165,7 +165,7 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(500L), list.get(2).getLongValue()); list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.MAX))).get(); + 60000, 20000, 3, Aggregation.MAX))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); @@ -178,7 +178,7 @@ public class TimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(600L), list.get(2).getLongValue()); list = tsService.findAll(DataConstants.DEVICE, deviceId, Collections.singletonList(new BaseTsKvQuery(LONG_KEY, 0, - 60000, 3, Aggregation.COUNT))).get(); + 60000, 20000, 3, Aggregation.COUNT))).get(); assertEquals(3, list.size()); assertEquals(10000, list.get(0).getTs()); diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java index 9f068950ee..145f8c483a 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/GetHistoryCmd.java @@ -32,6 +32,7 @@ public class GetHistoryCmd implements TelemetryPluginCmd { private String keys; private long startTs; private long endTs; + private long interval; private int limit; private String agg; diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java index 20bd3e2e07..9f3f7ec973 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/cmd/TimeseriesSubscriptionCmd.java @@ -30,6 +30,7 @@ public class TimeseriesSubscriptionCmd extends SubscriptionCmd { private long startTs; private long timeWindow; + private long interval; private int limit; private String agg; diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java index 78fa4ad873..fb504843d8 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java @@ -89,11 +89,12 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler { String keysStr = request.getParameter("keys"); Optional startTs = request.getLongParamValue("startTs"); Optional endTs = request.getLongParamValue("endTs"); + Optional interval = request.getLongParamValue("interval"); Optional limit = request.getIntParamValue("limit"); Aggregation agg = Aggregation.valueOf(request.getParameter("agg", Aggregation.NONE.name())); List keys = Arrays.asList(keysStr.split(",")); - List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs.get(), endTs.get(), limit.orElse(TelemetryWebsocketMsgHandler.DEFAULT_LIMIT), agg)).collect(Collectors.toList()); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs.get(), endTs.get(), interval.get(), limit.orElse(TelemetryWebsocketMsgHandler.DEFAULT_LIMIT), agg)).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { @Override public void onSuccess(PluginContext ctx, List data) { diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java index 51181fda8e..f018aaab13 100644 --- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java +++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryWebsocketMsgHandler.java @@ -193,7 +193,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { log.debug("[{}] fetching timeseries data for last {} ms for keys: ({}) for device : {}", sessionId, cmd.getTimeWindow(), cmd.getKeys(), cmd.getDeviceId()); startTs = cmd.getStartTs(); long endTs = cmd.getStartTs() + cmd.getTimeWindow(); - List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs, endTs, getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, startTs, endTs, cmd.getInterval(), getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, getSubscriptionCallback(sessionRef, cmd, sessionId, deviceId, startTs, keys)); } else { List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); @@ -277,7 +277,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { } DeviceId deviceId = DeviceId.fromString(cmd.getDeviceId()); List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); - List queries = keys.stream().map(key -> new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); + List queries = keys.stream().map(key -> new BaseTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), cmd.getInterval(), getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); ctx.loadTimeseries(deviceId, queries, new PluginCallback>() { @Override public void onSuccess(PluginContext ctx, List data) { diff --git a/ui/src/app/api/data-aggregator.js b/ui/src/app/api/data-aggregator.js index e273a9d513..31baff134e 100644 --- a/ui/src/app/api/data-aggregator.js +++ b/ui/src/app/api/data-aggregator.js @@ -25,11 +25,12 @@ export default class DataAggregator { this.$timeout = $timeout; this.$filter = $filter; this.dataReceived = false; + this.resetPending = false; this.noAggregation = aggregationType === types.aggregation.none.value; this.limit = limit; this.timeWindow = timeWindow; this.interval = interval; - this.aggregationTimeout = this.interval; + this.aggregationTimeout = Math.max(this.interval, 1000); switch (aggregationType) { case types.aggregation.min.value: this.aggFunction = min; @@ -54,11 +55,37 @@ export default class DataAggregator { } } + reset(startTs, timeWindow, interval) { + if (this.intervalTimeoutHandle) { + this.$timeout.cancel(this.intervalTimeoutHandle); + this.intervalTimeoutHandle = null; + } + this.intervalScheduledTime = currentTime(); + this.startTs = startTs; + this.timeWindow = timeWindow; + this.interval = interval; + this.endTs = this.startTs + this.timeWindow; + this.elapsed = 0; + this.aggregationTimeout = Math.max(this.interval, 1000); + this.resetPending = true; + var self = this; + this.intervalTimeoutHandle = this.$timeout(function() { + self.onInterval(); + }, this.aggregationTimeout, false); + } + onData(data, update, history) { - if (!this.dataReceived) { - this.elapsed = 0; - this.dataReceived = true; - this.endTs = this.startTs + this.timeWindow; + if (!this.dataReceived || this.resetPending) { + var updateIntervalScheduledTime = true; + if (!this.dataReceived) { + this.elapsed = 0; + this.dataReceived = true; + this.endTs = this.startTs + this.timeWindow; + } + if (this.resetPending) { + this.resetPending = false; + updateIntervalScheduledTime = false; + } if (update) { this.aggregationMap = {}; updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, @@ -66,19 +93,24 @@ export default class DataAggregator { } else { this.aggregationMap = processAggregatedData(data.data, this.aggregationType === this.types.aggregation.count.value, this.noAggregation); } - this.onInterval(currentTime(), history); + if (updateIntervalScheduledTime) { + this.intervalScheduledTime = currentTime(); + } + this.onInterval(history); } else { updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, this.noAggregation, this.aggFunction, data.data, this.interval, this.startTs); if (history) { - this.onInterval(currentTime(), history); + this.intervalScheduledTime = currentTime(); + this.onInterval(history); } } } - onInterval(startedTime, history) { + onInterval(history) { var now = currentTime(); - this.elapsed += now - startedTime; + this.elapsed += now - this.intervalScheduledTime; + this.intervalScheduledTime = now; if (this.intervalTimeoutHandle) { this.$timeout.cancel(this.intervalTimeoutHandle); this.intervalTimeoutHandle = null; @@ -101,16 +133,11 @@ export default class DataAggregator { var self = this; if (!history) { this.intervalTimeoutHandle = this.$timeout(function() { - self.onInterval(now); + self.onInterval(); }, this.aggregationTimeout, false); } } - reset() { - this.destroy(); - this.dataReceived = false; - } - destroy() { if (this.intervalTimeoutHandle) { this.$timeout.cancel(this.intervalTimeoutHandle); diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js index b44f85d18d..7d2c1be893 100644 --- a/ui/src/app/api/datasource.service.js +++ b/ui/src/app/api/datasource.service.js @@ -254,6 +254,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic keys: tsKeys, startTs: subsTw.fixedWindow.startTimeMs, endTs: subsTw.fixedWindow.endTimeMs, + interval: subsTw.aggregation.interval, limit: subsTw.aggregation.limit, agg: subsTw.aggregation.type }; @@ -266,9 +267,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic onData(data.data, types.dataKeyType.timeseries); } }, - onReconnected: function() { - onReconnected(); - } + onReconnected: function() {} }; telemetryWebsocketService.subscribe(subscriber); @@ -287,35 +286,26 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic }; if (datasourceSubscription.type === types.widgetType.timeseries.value) { - subscriptionCommand.startTs = subsTw.startTs; - subscriptionCommand.timeWindow = subsTw.aggregation.timeWindow; - subscriptionCommand.limit = subsTw.aggregation.limit; - subscriptionCommand.agg = subsTw.aggregation.type; - dataAggregator = new DataAggregator( - function(data, startTs, endTs) { - onData(data, types.dataKeyType.timeseries, startTs, endTs); - }, - tsKeyNames, - subsTw.startTs, - subsTw.aggregation.limit, - subsTw.aggregation.type, - subsTw.aggregation.timeWindow, - subsTw.aggregation.interval, - types, - $timeout, - $filter - ); + updateRealtimeSubscriptionCommand(subscriptionCommand, subsTw); + dataAggregator = createRealtimeDataAggregator(subsTw, tsKeyNames); subscriber.onData = function(data) { dataAggregator.onData(data); } subscriber.onReconnected = function() { - dataAggregator.reset(); - onReconnected(); + var newSubsTw = null; + for (var i2 in listeners) { + var listener = listeners[i2]; + if (!newSubsTw) { + newSubsTw = listener.updateRealtimeSubscription(); + } else { + listener.setRealtimeSubscription(newSubsTw); + } + } + updateRealtimeSubscriptionCommand(this.subscriptionCommand, newSubsTw); + dataAggregator.reset(newSubsTw.startTs, newSubsTw.aggregation.timeWindow, newSubsTw.aggregation.interval); } } else { - subscriber.onReconnected = function() { - onReconnected(); - } + subscriber.onReconnected = function() {} subscriber.onData = function(data) { if (data.data) { onData(data.data, types.dataKeyType.timeseries); @@ -344,9 +334,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic onData(data.data, types.dataKeyType.attribute); } }, - onReconnected: function() { - onReconnected(); - } + onReconnected: function() {} }; telemetryWebsocketService.subscribe(subscriber); @@ -384,7 +372,31 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic timer = $timeout(onTick, 0, false); } } + } + + function createRealtimeDataAggregator(subsTw, tsKeyNames) { + return new DataAggregator( + function(data, startTs, endTs) { + onData(data, types.dataKeyType.timeseries, startTs, endTs); + }, + tsKeyNames, + subsTw.startTs, + subsTw.aggregation.limit, + subsTw.aggregation.type, + subsTw.aggregation.timeWindow, + subsTw.aggregation.interval, + types, + $timeout, + $filter + ); + } + function updateRealtimeSubscriptionCommand(subscriptionCommand, subsTw) { + subscriptionCommand.startTs = subsTw.startTs; + subscriptionCommand.timeWindow = subsTw.aggregation.timeWindow; + subscriptionCommand.interval = subsTw.aggregation.interval; + subscriptionCommand.limit = subsTw.aggregation.limit; + subscriptionCommand.agg = subsTw.aggregation.type; } function unsubscribe() { @@ -495,27 +507,6 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } } - function onReconnected() { - if (datasourceType === types.datasourceType.device) { - for (var key in dataKeys) { - var dataKeysList = dataKeys[key]; - for (var i = 0; i < dataKeysList.length; i++) { - var dataKey = dataKeysList[i]; - var datasourceKey = key + '_' + i; - datasourceData[datasourceKey] = { - data: [] - }; - for (var l in listeners) { - var listener = listeners[l]; - listener.dataUpdated(datasourceData[datasourceKey], - listener.datasourceIndex, - dataKey.index); - } - } - } - } - } - function isNumeric(val) { return (val - parseFloat( val ) + 1) >= 0; } diff --git a/ui/src/app/api/time.service.js b/ui/src/app/api/time.service.js new file mode 100644 index 0000000000..a4bb571a59 --- /dev/null +++ b/ui/src/app/api/time.service.js @@ -0,0 +1,330 @@ +/* + * Copyright © 2016-2017 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. + */ +export default angular.module('thingsboard.api.time', []) + .factory('timeService', TimeService) + .name; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +const MIN_INTERVAL = SECOND; +const MAX_INTERVAL = 365 * 20 * DAY; + +const MIN_LIMIT = 10; +const AVG_LIMIT = 200; +const MAX_LIMIT = 500; + +/*@ngInject*/ +function TimeService($translate, types) { + + var predefIntervals = [ + { + name: $translate.instant('timeinterval.seconds-interval', {seconds: 1}, 'messageformat'), + value: 1 * SECOND + }, + { + name: $translate.instant('timeinterval.seconds-interval', {seconds: 5}, 'messageformat'), + value: 5 * SECOND + }, + { + name: $translate.instant('timeinterval.seconds-interval', {seconds: 10}, 'messageformat'), + value: 10 * SECOND + }, + { + name: $translate.instant('timeinterval.seconds-interval', {seconds: 15}, 'messageformat'), + value: 15 * SECOND + }, + { + name: $translate.instant('timeinterval.seconds-interval', {seconds: 30}, 'messageformat'), + value: 30 * SECOND + }, + { + name: $translate.instant('timeinterval.minutes-interval', {minutes: 1}, 'messageformat'), + value: 1 * MINUTE + }, + { + name: $translate.instant('timeinterval.minutes-interval', {minutes: 2}, 'messageformat'), + value: 2 * MINUTE + }, + { + name: $translate.instant('timeinterval.minutes-interval', {minutes: 5}, 'messageformat'), + value: 5 * MINUTE + }, + { + name: $translate.instant('timeinterval.minutes-interval', {minutes: 10}, 'messageformat'), + value: 10 * MINUTE + }, + { + name: $translate.instant('timeinterval.minutes-interval', {minutes: 15}, 'messageformat'), + value: 15 * MINUTE + }, + { + name: $translate.instant('timeinterval.minutes-interval', {minutes: 30}, 'messageformat'), + value: 30 * MINUTE + }, + { + name: $translate.instant('timeinterval.hours-interval', {hours: 1}, 'messageformat'), + value: 1 * HOUR + }, + { + name: $translate.instant('timeinterval.hours-interval', {hours: 2}, 'messageformat'), + value: 2 * HOUR + }, + { + name: $translate.instant('timeinterval.hours-interval', {hours: 5}, 'messageformat'), + value: 5 * HOUR + }, + { + name: $translate.instant('timeinterval.hours-interval', {hours: 10}, 'messageformat'), + value: 10 * HOUR + }, + { + name: $translate.instant('timeinterval.hours-interval', {hours: 12}, 'messageformat'), + value: 12 * HOUR + }, + { + name: $translate.instant('timeinterval.days-interval', {days: 1}, 'messageformat'), + value: 1 * DAY + }, + { + name: $translate.instant('timeinterval.days-interval', {days: 7}, 'messageformat'), + value: 7 * DAY + }, + { + name: $translate.instant('timeinterval.days-interval', {days: 30}, 'messageformat'), + value: 30 * DAY + } + ]; + + var service = { + minIntervalLimit: minIntervalLimit, + maxIntervalLimit: maxIntervalLimit, + boundMinInterval: boundMinInterval, + boundMaxInterval: boundMaxInterval, + getIntervals: getIntervals, + matchesExistingInterval: matchesExistingInterval, + boundToPredefinedInterval: boundToPredefinedInterval, + defaultTimewindow: defaultTimewindow, + toHistoryTimewindow: toHistoryTimewindow, + createSubscriptionTimewindow: createSubscriptionTimewindow, + avgAggregationLimit: function () { + return AVG_LIMIT; + } + } + + return service; + + function minIntervalLimit(timewindow) { + var min = timewindow / MAX_LIMIT; + return boundMinInterval(min); + } + + function avgInterval(timewindow) { + var avg = timewindow / AVG_LIMIT; + return boundMinInterval(avg); + } + + function maxIntervalLimit(timewindow) { + var max = timewindow / MIN_LIMIT; + return boundMaxInterval(max); + } + + function boundMinInterval(min) { + return toBound(min, MIN_INTERVAL, MAX_INTERVAL, MIN_INTERVAL); + } + + function boundMaxInterval(max) { + return toBound(max, MIN_INTERVAL, MAX_INTERVAL, MAX_INTERVAL); + } + + function toBound(value, min, max, defValue) { + if (angular.isDefined(value)) { + value = Math.max(value, min); + value = Math.min(value, max); + return value; + } else { + return defValue; + } + } + + function getIntervals(min, max) { + min = boundMinInterval(min); + max = boundMaxInterval(max); + var intervals = []; + for (var i in predefIntervals) { + var interval = predefIntervals[i]; + if (interval.value >= min && interval.value <= max) { + intervals.push(interval); + } + } + return intervals; + } + + function matchesExistingInterval(min, max, intervalMs) { + var intervals = getIntervals(min, max); + for (var i in intervals) { + var interval = intervals[i]; + if (intervalMs === interval.value) { + return true; + } + } + return false; + } + + function boundToPredefinedInterval(min, max, intervalMs) { + var intervals = getIntervals(min, max); + var minDelta = MAX_INTERVAL; + var boundedInterval = intervalMs || min; + var matchedInterval; + for (var i in intervals) { + var interval = intervals[i]; + var delta = Math.abs(interval.value - boundedInterval); + if (delta < minDelta) { + matchedInterval = interval; + minDelta = delta; + } + } + boundedInterval = matchedInterval.value; + return boundedInterval; + } + + function defaultTimewindow() { + var currentTime = (new Date).getTime(); + var timewindow = { + displayValue: "", + selectedTab: 0, + realtime: { + interval: SECOND, + timewindowMs: MINUTE // 1 min by default + }, + history: { + historyType: 0, + interval: SECOND, + timewindowMs: MINUTE, // 1 min by default + fixedTimewindow: { + startTimeMs: currentTime - DAY, // 1 day by default + endTimeMs: currentTime + } + }, + aggregation: { + type: types.aggregation.avg.value, + limit: AVG_LIMIT + } + } + return timewindow; + } + + function toHistoryTimewindow(timewindow, startTimeMs, endTimeMs) { + + var interval = 0; + if (timewindow.history) { + interval = timewindow.history.interval; + } else if (timewindow.realtime) { + interval = timewindow.realtime.interval; + } + + var historyTimewindow = { + history: { + fixedTimewindow: { + startTimeMs: startTimeMs, + endTimeMs: endTimeMs + }, + interval: boundIntervalToTimewindow(endTimeMs - startTimeMs, interval) + }, + aggregation: { + + } + } + if (timewindow.aggregation) { + historyTimewindow.aggregation.type = timewindow.aggregation.type || types.aggregation.avg.value; + } else { + historyTimewindow.aggregation.type = types.aggregation.avg.value; + } + + return historyTimewindow; + } + + function createSubscriptionTimewindow(timewindow, stDiff) { + + var subscriptionTimewindow = { + fixedWindow: null, + realtimeWindowMs: null, + aggregation: { + interval: SECOND, + limit: AVG_LIMIT, + type: types.aggregation.avg.value + } + }; + var aggTimewindow = 0; + + if (angular.isDefined(timewindow.aggregation)) { + subscriptionTimewindow.aggregation = { + type: timewindow.aggregation.type || types.aggregation.avg.value, + limit: timewindow.aggregation.limit || AVG_LIMIT + }; + } + if (angular.isDefined(timewindow.realtime)) { + subscriptionTimewindow.realtimeWindowMs = timewindow.realtime.timewindowMs; + subscriptionTimewindow.aggregation.interval = + boundIntervalToTimewindow(subscriptionTimewindow.realtimeWindowMs, timewindow.realtime.interval); + subscriptionTimewindow.startTs = (new Date).getTime() + stDiff - subscriptionTimewindow.realtimeWindowMs; + var startDiff = subscriptionTimewindow.startTs % subscriptionTimewindow.aggregation.interval; + aggTimewindow = subscriptionTimewindow.realtimeWindowMs; + if (startDiff) { + subscriptionTimewindow.startTs -= startDiff; + aggTimewindow += subscriptionTimewindow.aggregation.interval; + } + } else if (angular.isDefined(timewindow.history)) { + if (angular.isDefined(timewindow.history.timewindowMs)) { + var currentTime = (new Date).getTime(); + subscriptionTimewindow.fixedWindow = { + startTimeMs: currentTime - timewindow.history.timewindowMs, + endTimeMs: currentTime + } + aggTimewindow = timewindow.history.timewindowMs; + + } else { + subscriptionTimewindow.fixedWindow = { + startTimeMs: timewindow.history.fixedTimewindow.startTimeMs, + endTimeMs: timewindow.history.fixedTimewindow.endTimeMs + } + aggTimewindow = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + } + subscriptionTimewindow.startTs = subscriptionTimewindow.fixedWindow.startTimeMs; + subscriptionTimewindow.aggregation.interval = boundIntervalToTimewindow(aggTimewindow, timewindow.history.interval); + } + var aggregation = subscriptionTimewindow.aggregation; + aggregation.timeWindow = aggTimewindow; + if (aggregation.type !== types.aggregation.none.value) { + aggregation.limit = Math.ceil(aggTimewindow / subscriptionTimewindow.aggregation.interval); + } + return subscriptionTimewindow; + } + + function boundIntervalToTimewindow(timewindow, intervalMs) { + var min = minIntervalLimit(timewindow); + var max = maxIntervalLimit(timewindow); + if (intervalMs) { + return toBound(intervalMs, min, max, intervalMs); + } else { + return boundToPredefinedInterval(min, max, avgInterval(timewindow)); + } + } + + +} \ No newline at end of file diff --git a/ui/src/app/api/widget.service.js b/ui/src/app/api/widget.service.js index 3d750e3c0b..8d6359a899 100644 --- a/ui/src/app/api/widget.service.js +++ b/ui/src/app/api/widget.service.js @@ -129,7 +129,7 @@ function WidgetService($rootScope, $http, $q, $filter, $ocLazyLoad, $window, typ resources: [], templateHtml: '
widget.widget-type-not-found
', templateCss: '', - controllerScript: 'fns.init = function(containerElement, settings, datasources,\n data) {}\n\n\nfns.redraw = function(containerElement, width, height, data) {};', + controllerScript: 'self.onInit = function() {}', settingsSchema: '{}\n', dataKeySettingsSchema: '{}\n', defaultConfig: '{\n' + @@ -147,7 +147,7 @@ function WidgetService($rootScope, $http, $q, $filter, $ocLazyLoad, $window, typ resources: [], templateHtml: '
widget.widget-type-load-error
', templateCss: '', - controllerScript: 'fns.init = function(containerElement, settings, datasources,\n data) {}\n\n\nfns.redraw = function(containerElement, width, height, data) {};', + controllerScript: 'self.onInit = function() {}', settingsSchema: '{}\n', dataKeySettingsSchema: '{}\n', defaultConfig: '{\n' + diff --git a/ui/src/app/app.js b/ui/src/app/app.js index 3acb1c0584..5e09b38e38 100644 --- a/ui/src/app/app.js +++ b/ui/src/app/app.js @@ -51,6 +51,7 @@ import thingsboardMenu from './services/menu.service'; import thingsboardRaf from './common/raf.provider'; import thingsboardUtils from './common/utils.service'; import thingsboardTypes from './common/types.constant'; +import thingsboardApiTime from './api/time.service'; import thingsboardKeyboardShortcut from './components/keyboard-shortcut.filter'; import thingsboardHelp from './help/help.directive'; import thingsboardToast from './services/toast'; @@ -101,6 +102,7 @@ angular.module('thingsboard', [ thingsboardRaf, thingsboardUtils, thingsboardTypes, + thingsboardApiTime, thingsboardKeyboardShortcut, thingsboardHelp, thingsboardToast, diff --git a/ui/src/app/components/timeinterval.directive.js b/ui/src/app/components/timeinterval.directive.js index 47251b019d..eaaa4a1024 100644 --- a/ui/src/app/components/timeinterval.directive.js +++ b/ui/src/app/components/timeinterval.directive.js @@ -26,7 +26,7 @@ export default angular.module('thingsboard.directives.timeinterval', []) .name; /*@ngInject*/ -function Timeinterval($compile, $templateCache, $translate) { +function Timeinterval($compile, $templateCache, timeService) { var linker = function (scope, element, attrs, ngModelCtrl) { @@ -39,62 +39,33 @@ function Timeinterval($compile, $templateCache, $translate) { scope.mins = 1; scope.secs = 0; - scope.predefIntervals = [ - { - name: $translate.instant('timeinterval.seconds-interval', {seconds: 10}, 'messageformat'), - value: 10 * 1000 - }, - { - name: $translate.instant('timeinterval.seconds-interval', {seconds: 30}, 'messageformat'), - value: 30 * 1000 - }, - { - name: $translate.instant('timeinterval.minutes-interval', {minutes: 1}, 'messageformat'), - value: 60 * 1000 - }, - { - name: $translate.instant('timeinterval.minutes-interval', {minutes: 2}, 'messageformat'), - value: 2 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.minutes-interval', {minutes: 5}, 'messageformat'), - value: 5 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.minutes-interval', {minutes: 10}, 'messageformat'), - value: 10 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.minutes-interval', {minutes: 30}, 'messageformat'), - value: 30 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.hours-interval', {hours: 1}, 'messageformat'), - value: 60 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.hours-interval', {hours: 2}, 'messageformat'), - value: 2 * 60 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.hours-interval', {hours: 10}, 'messageformat'), - value: 10 * 60 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.days-interval', {days: 1}, 'messageformat'), - value: 24 * 60 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.days-interval', {days: 7}, 'messageformat'), - value: 7 * 24 * 60 * 60 * 1000 - }, - { - name: $translate.instant('timeinterval.days-interval', {days: 30}, 'messageformat'), - value: 30 * 24 * 60 * 60 * 1000 - } - ]; + scope.advanced = false; + + scope.boundInterval = function() { + var min = timeService.boundMinInterval(scope.min); + var max = timeService.boundMaxInterval(scope.max); + scope.intervals = timeService.getIntervals(scope.min, scope.max); + if (scope.rendered) { + var newIntervalMs = ngModelCtrl.$viewValue; + if (newIntervalMs < min) { + newIntervalMs = min; + } else if (newIntervalMs > max) { + newIntervalMs = max; + } + if (!scope.advanced) { + newIntervalMs = timeService.boundToPredefinedInterval(min, max, newIntervalMs); + } + if (newIntervalMs !== ngModelCtrl.$viewValue) { + scope.setIntervalMs(newIntervalMs); + scope.updateView(); + } + } + } scope.setIntervalMs = function (intervalMs) { + if (!scope.advanced) { + scope.intervalMs = intervalMs; + } var intervalSeconds = Math.floor(intervalMs / 1000); scope.days = Math.floor(intervalSeconds / 86400); scope.hours = Math.floor((intervalSeconds % 86400) / 3600); @@ -105,6 +76,9 @@ function Timeinterval($compile, $templateCache, $translate) { ngModelCtrl.$render = function () { if (ngModelCtrl.$viewValue) { var intervalMs = ngModelCtrl.$viewValue; + if (!scope.rendered) { + scope.advanced = !timeService.matchesExistingInterval(scope.min, scope.max, intervalMs); + } scope.setIntervalMs(intervalMs); } scope.rendered = true; @@ -115,10 +89,15 @@ function Timeinterval($compile, $templateCache, $translate) { return; } var value = null; - var intervalMs = (scope.days * 86400 + + var intervalMs; + if (!scope.advanced) { + intervalMs = scope.intervalMs; + } else { + intervalMs = (scope.days * 86400 + scope.hours * 3600 + scope.mins * 60 + scope.secs) * 1000; + } if (!isNaN(intervalMs) && intervalMs > 0) { value = intervalMs; ngModelCtrl.$setValidity('tb-timeinterval', true); @@ -126,6 +105,7 @@ function Timeinterval($compile, $templateCache, $translate) { ngModelCtrl.$setValidity('tb-timeinterval', !scope.required); } ngModelCtrl.$setViewValue(value); + scope.boundInterval(); } scope.$watch('required', function (newRequired, prevRequired) { @@ -134,6 +114,38 @@ function Timeinterval($compile, $templateCache, $translate) { } }); + scope.$watch('min', function (newMin, prevMin) { + if (angular.isDefined(newMin) && newMin !== prevMin) { + scope.updateView(); + } + }); + + scope.$watch('max', function (newMax, prevMax) { + if (angular.isDefined(newMax) && newMax !== prevMax) { + scope.updateView(); + } + }); + + scope.$watch('intervalMs', function (newIntervalMs, prevIntervalMs) { + if (angular.isDefined(newIntervalMs) && newIntervalMs !== prevIntervalMs) { + scope.updateView(); + } + }); + + scope.$watch('advanced', function (newAdvanced, prevAdvanced) { + if (angular.isDefined(newAdvanced) && newAdvanced !== prevAdvanced) { + if (!scope.advanced) { + scope.intervalMs = (scope.days * 86400 + + scope.hours * 3600 + + scope.mins * 60 + + scope.secs) * 1000; + } else { + scope.setIntervalMs(scope.intervalMs); + } + scope.updateView(); + } + }); + scope.$watch('secs', function (newSecs) { if (angular.isUndefined(newSecs)) { return; @@ -198,6 +210,8 @@ function Timeinterval($compile, $templateCache, $translate) { scope.updateView(); }); + scope.boundInterval(); + $compile(element.contents())(scope); } @@ -206,7 +220,10 @@ function Timeinterval($compile, $templateCache, $translate) { restrict: "E", require: "^ngModel", scope: { - required: '=ngRequired' + required: '=ngRequired', + min: '=?', + max: '=?', + predefinedName: '=?' }, link: linker }; diff --git a/ui/src/app/components/timeinterval.scss b/ui/src/app/components/timeinterval.scss index 2d7af717e1..525bfd12fa 100644 --- a/ui/src/app/components/timeinterval.scss +++ b/ui/src/app/components/timeinterval.scss @@ -14,6 +14,7 @@ * limitations under the License. */ tb-timeinterval { + min-width: 355px; md-input-container { margin-bottom: 0px; .md-errors-spacer { @@ -25,10 +26,13 @@ tb-timeinterval { width: 150px; } } -} - -tb-timeinterval { .md-input { width: 70px !important; } + .advanced-switch { + margin-top: 0; + } + .advanced-label { + margin: 5px 0; + } } diff --git a/ui/src/app/components/timeinterval.tpl.html b/ui/src/app/components/timeinterval.tpl.html index 75ecd30cce..e6719f2ae5 100644 --- a/ui/src/app/components/timeinterval.tpl.html +++ b/ui/src/app/components/timeinterval.tpl.html @@ -15,33 +15,41 @@ limitations under the License. --> -
- - - - - - - - - - - - - - - - - - - arrow_drop_down - - - - - {{interval.name}} - - - - +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + + + + {{interval.name}} + + + +
+
+ + + +
diff --git a/ui/src/app/components/timewindow-panel.controller.js b/ui/src/app/components/timewindow-panel.controller.js index ab81e9d45c..e6af0b06f0 100644 --- a/ui/src/app/components/timewindow-panel.controller.js +++ b/ui/src/app/components/timewindow-panel.controller.js @@ -14,7 +14,7 @@ * limitations under the License. */ /*@ngInject*/ -export default function TimewindowPanelController(mdPanelRef, $scope, types, timewindow, historyOnly, aggregation, onTimewindowUpdate) { +export default function TimewindowPanelController(mdPanelRef, $scope, timeService, types, timewindow, historyOnly, aggregation, onTimewindowUpdate) { var vm = this; @@ -24,6 +24,13 @@ export default function TimewindowPanelController(mdPanelRef, $scope, types, tim vm.aggregation = aggregation; vm.onTimewindowUpdate = onTimewindowUpdate; vm.aggregationTypes = types.aggregation; + vm.showLimit = showLimit; + vm.showRealtimeAggInterval = showRealtimeAggInterval; + vm.showHistoryAggInterval = showHistoryAggInterval; + vm.minRealtimeAggInterval = minRealtimeAggInterval; + vm.maxRealtimeAggInterval = maxRealtimeAggInterval; + vm.minHistoryAggInterval = minHistoryAggInterval; + vm.maxHistoryAggInterval = maxHistoryAggInterval; if (vm.historyOnly) { vm.timewindow.selectedTab = 1; @@ -48,4 +55,45 @@ export default function TimewindowPanelController(mdPanelRef, $scope, types, tim vm.onTimewindowUpdate && vm.onTimewindowUpdate(vm.timewindow); }); }; + + function showLimit() { + return vm.timewindow.aggregation.type === vm.aggregationTypes.none.value; + } + + function showRealtimeAggInterval() { + return vm.timewindow.aggregation.type !== vm.aggregationTypes.none.value && + vm.timewindow.selectedTab === 0; + } + + function showHistoryAggInterval() { + return vm.timewindow.aggregation.type !== vm.aggregationTypes.none.value && + vm.timewindow.selectedTab === 1; + } + + function minRealtimeAggInterval () { + return timeService.minIntervalLimit(vm.timewindow.realtime.timewindowMs); + } + + function maxRealtimeAggInterval () { + return timeService.maxIntervalLimit(vm.timewindow.realtime.timewindowMs); + } + + function minHistoryAggInterval () { + return timeService.minIntervalLimit(currentHistoryTimewindow()); + } + + function maxHistoryAggInterval () { + return timeService.maxIntervalLimit(currentHistoryTimewindow()); + } + + function currentHistoryTimewindow() { + if (vm.timewindow.history.historyType === 0) { + return vm.timewindow.history.timewindowMs; + } else { + return vm.timewindow.history.fixedTimewindow.endTimeMs - + vm.timewindow.history.fixedTimewindow.startTimeMs; + } + } + } + diff --git a/ui/src/app/components/timewindow-panel.tpl.html b/ui/src/app/components/timewindow-panel.tpl.html index 89825b09d0..b7550e8ce1 100644 --- a/ui/src/app/components/timewindow-panel.tpl.html +++ b/ui/src/app/components/timewindow-panel.tpl.html @@ -17,61 +17,70 @@ -->
- - - - - timewindow.last - - - - - - - -
- timewindow.last - -
-
- -
- timewindow.time-period - -
-
-
-
-
-
- - - - - - {{type.name | translate}} - - - - - aggregation.limit - - + +
+ + + + + + + + + + +
+ +
+
+ +
+ timewindow.time-period + +
+
+
+
+
+
+ - + + + + {{type.name | translate}} + + - - + + aggregation.limit + + + + + + + + + + + +
+
- + {{ 'action.update' | translate }} @@ -79,6 +88,6 @@ {{ 'action.cancel' | translate }}
-
+ \ No newline at end of file diff --git a/ui/src/app/components/timewindow.directive.js b/ui/src/app/components/timewindow.directive.js index f06c1198b9..14fff62b47 100644 --- a/ui/src/app/components/timewindow.directive.js +++ b/ui/src/app/components/timewindow.directive.js @@ -37,16 +37,18 @@ export default angular.module('thingsboard.directives.timewindow', [thingsboardT /* eslint-disable angular/angularelement */ /*@ngInject*/ -function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdMedia, $translate, types) { +function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdMedia, $translate, timeService) { var linker = function (scope, element, attrs, ngModelCtrl) { /* tbTimewindow (ng-model) * { * realtime: { + * interval: 0, * timewindowMs: 0 * }, * history: { + * interval: 0, * timewindowMs: 0, * fixedTimewindow: { * startTimeMs: 0, @@ -54,8 +56,8 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM * } * }, * aggregation: { - * limit: 200, - * type: types.aggregation.avg.value + * type: types.aggregation.avg.value, + * limit: 200 * } * } */ @@ -81,16 +83,6 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM } element.html(template); - scope.isHovered = false; - - scope.onHoverIn = function () { - scope.isHovered = true; - } - - scope.onHoverOut = function () { - scope.isHovered = false; - } - scope.openEditMode = function (event) { var position; var isGtSm = $mdMedia('gt-sm'); @@ -143,15 +135,18 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM var model = scope.model; if (model.selectedTab === 0) { value.realtime = { + interval: model.realtime.interval, timewindowMs: model.realtime.timewindowMs }; } else { if (model.history.historyType === 0) { value.history = { + interval: model.history.interval, timewindowMs: model.history.timewindowMs }; } else { value.history = { + interval: model.history.interval, fixedTimewindow: { startTimeMs: model.history.fixedTimewindow.startTimeMs, endTimeMs: model.history.fixedTimewindow.endTimeMs @@ -160,8 +155,8 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM } } value.aggregation = { - limit: model.aggregation.limit, - type: model.aggregation.type + type: model.aggregation.type, + limit: model.aggregation.limit }; ngModelCtrl.$setViewValue(value); scope.updateDisplayValue(); @@ -190,34 +185,17 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM } ngModelCtrl.$render = function () { - var currentTime = (new Date).getTime(); - scope.model = { - displayValue: "", - selectedTab: 0, - realtime: { - timewindowMs: 60000 // 1 min by default - }, - history: { - historyType: 0, - timewindowMs: 60000, // 1 min by default - fixedTimewindow: { - startTimeMs: currentTime - 24 * 60 * 60 * 1000, // 1 day by default - endTimeMs: currentTime - } - }, - aggregation: { - limit: 200, - type: types.aggregation.avg.value - } - }; + scope.model = timeService.defaultTimewindow(); if (ngModelCtrl.$viewValue) { var value = ngModelCtrl.$viewValue; var model = scope.model; if (angular.isDefined(value.realtime)) { model.selectedTab = 0; + model.realtime.interval = value.realtime.interval; model.realtime.timewindowMs = value.realtime.timewindowMs; } else { model.selectedTab = 1; + model.history.interval = value.history.interval; if (angular.isDefined(value.history.timewindowMs)) { model.history.historyType = 0; model.history.timewindowMs = value.history.timewindowMs; @@ -228,10 +206,10 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM } } if (angular.isDefined(value.aggregation)) { - model.aggregation.limit = value.aggregation.limit || 200; if (angular.isDefined(value.aggregation.type) && value.aggregation.type.length > 0) { model.aggregation.type = value.aggregation.type; } + model.aggregation.limit = value.aggregation.limit || timeService.avgAggregationLimit(); } } scope.updateDisplayValue(); diff --git a/ui/src/app/components/timewindow.scss b/ui/src/app/components/timewindow.scss index 16c89e81dd..0c85d521e3 100644 --- a/ui/src/app/components/timewindow.scss +++ b/ui/src/app/components/timewindow.scss @@ -21,14 +21,39 @@ } .tb-timewindow-panel { - min-height: 375px; + max-height: 440px; + min-width: 417px; background: white; border-radius: 4px; box-shadow: 0 7px 8px -4px rgba(0, 0, 0, 0.2), 0 13px 19px 2px rgba(0, 0, 0, 0.14), 0 5px 24px 4px rgba(0, 0, 0, 0.12); overflow: hidden; + form, fieldset { + height: 100%; + } md-content { background-color: #fff; + overflow: hidden; + } + .md-padding { + padding: 0 16px; + } + .md-radio-interactive { + md-select, md-switch { + pointer-events: all; + } + } + md-radio-button { + .md-label { + width: 100%; + } + tb-timeinterval { + width: 355px; + .advanced-switch { + min-height: 30px; + max-width: 44px; + } + } } } diff --git a/ui/src/app/components/timewindow.tpl.html b/ui/src/app/components/timewindow.tpl.html index e428365f08..0de81e2bad 100644 --- a/ui/src/app/components/timewindow.tpl.html +++ b/ui/src/app/components/timewindow.tpl.html @@ -15,9 +15,9 @@ limitations under the License. --> -
+
{{model.displayValue}} - + date_range
\ No newline at end of file diff --git a/ui/src/app/components/widget.controller.js b/ui/src/app/components/widget.controller.js index f7e9498f7e..a2056e0b36 100644 --- a/ui/src/app/components/widget.controller.js +++ b/ui/src/app/components/widget.controller.js @@ -19,7 +19,7 @@ import 'javascript-detect-element-resize/detect-element-resize'; /* eslint-disable angular/angularelement */ /*@ngInject*/ -export default function WidgetController($scope, $timeout, $window, $element, $q, $log, $injector, tbRaf, types, utils, +export default function WidgetController($scope, $timeout, $window, $element, $q, $log, $injector, tbRaf, types, utils, timeService, datasourceService, deviceService, visibleRect, isEdit, stDiff, widget, deviceAliasList, widgetType) { var vm = this; @@ -41,11 +41,7 @@ export default function WidgetController($scope, $timeout, $window, $element, $q var targetDeviceAliasId = null; var targetDeviceId = null; var originalTimewindow = null; - var subscriptionTimewindow = { - fixedWindow: null, - realtimeWindowMs: null, - aggregation: null - }; + var subscriptionTimewindow = null; var dataUpdateCaf = null; /* @@ -488,15 +484,7 @@ export default function WidgetController($scope, $timeout, $window, $element, $q if (!originalTimewindow) { originalTimewindow = angular.copy(widget.config.timewindow); } - widget.config.timewindow = { - history: { - fixedTimewindow: { - startTimeMs: startTimeMs, - endTimeMs: endTimeMs - } - }, - aggregation: angular.copy(widget.config.timewindow.aggregation) - }; + widget.config.timewindow = timeService.toHistoryTimewindow(widget.config.timewindow, startTimeMs, endTimeMs); } function dataUpdated(sourceData, datasourceIndex, dataKeyIndex) { @@ -511,7 +499,7 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } } if (update) { - if (subscriptionTimewindow.realtimeWindowMs) { + if (subscriptionTimewindow && subscriptionTimewindow.realtimeWindowMs) { updateTimewindow(); } widgetContext.data[datasourceIndex + dataKeyIndex].data = sourceData.data; @@ -555,62 +543,26 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } } + function updateRealtimeSubscription(_subscriptionTimewindow) { + if (_subscriptionTimewindow) { + subscriptionTimewindow = _subscriptionTimewindow; + } else { + subscriptionTimewindow = timeService.createSubscriptionTimewindow(widget.config.timewindow, widgetContext.timeWindow.stDiff); + } + updateTimewindow(); + return subscriptionTimewindow; + } + function subscribe() { if (widget.type !== types.widgetType.rpc.value) { - var index = 0; - subscriptionTimewindow.fixedWindow = null; - subscriptionTimewindow.realtimeWindowMs = null; - subscriptionTimewindow.aggregation = { - limit: 200, - type: types.aggregation.avg.value - }; if (widget.type === types.widgetType.timeseries.value && angular.isDefined(widget.config.timewindow)) { - var timeWindow = 0; - if (angular.isDefined(widget.config.timewindow.aggregation)) { - subscriptionTimewindow.aggregation = { - limit: widget.config.timewindow.aggregation.limit || 200, - type: widget.config.timewindow.aggregation.type || types.aggregation.avg.value - }; - } - - if (angular.isDefined(widget.config.timewindow.realtime)) { - subscriptionTimewindow.realtimeWindowMs = widget.config.timewindow.realtime.timewindowMs; - subscriptionTimewindow.startTs = (new Date).getTime() + widgetContext.timeWindow.stDiff - subscriptionTimewindow.realtimeWindowMs; - timeWindow = subscriptionTimewindow.realtimeWindowMs; - } else if (angular.isDefined(widget.config.timewindow.history)) { - if (angular.isDefined(widget.config.timewindow.history.timewindowMs)) { - var currentTime = (new Date).getTime(); - subscriptionTimewindow.fixedWindow = { - startTimeMs: currentTime - widget.config.timewindow.history.timewindowMs, - endTimeMs: currentTime - } - timeWindow = widget.config.timewindow.history.timewindowMs; - } else { - subscriptionTimewindow.fixedWindow = { - startTimeMs: widget.config.timewindow.history.fixedTimewindow.startTimeMs, - endTimeMs: widget.config.timewindow.history.fixedTimewindow.endTimeMs - } - timeWindow = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; - } - subscriptionTimewindow.startTs = subscriptionTimewindow.fixedWindow.startTimeMs; - } - var aggregation = subscriptionTimewindow.aggregation; - var noAggregation = aggregation.type === types.aggregation.none.value; - var interval = Math.floor(timeWindow / aggregation.limit); - if (!noAggregation) { - aggregation.interval = Math.max(interval, 1000); - aggregation.limit = Math.ceil(interval/aggregation.interval * aggregation.limit); - aggregation.timeWindow = aggregation.interval * aggregation.limit; - } else { - aggregation.timeWindow = interval * aggregation.limit; - aggregation.interval = 1000; - } - updateTimewindow(); + updateRealtimeSubscription(); if (subscriptionTimewindow.fixedWindow) { onDataUpdated(); } } + var index = 0; for (var i in widget.config.datasources) { var datasource = widget.config.datasources[i]; var deviceId = null; @@ -630,6 +582,13 @@ export default function WidgetController($scope, $timeout, $window, $element, $q dataUpdated: function (data, datasourceIndex, dataKeyIndex) { dataUpdated(data, datasourceIndex, dataKeyIndex); }, + updateRealtimeSubscription: function() { + this.subscriptionTimewindow = updateRealtimeSubscription(); + return this.subscriptionTimewindow; + }, + setRealtimeSubscription: function(subscriptionTimewindow) { + updateRealtimeSubscription(angular.copy(subscriptionTimewindow)); + }, datasourceIndex: index }; diff --git a/ui/src/app/device/attribute/attribute-table.directive.js b/ui/src/app/device/attribute/attribute-table.directive.js index ba3e4662a7..c25c6f2013 100644 --- a/ui/src/app/device/attribute/attribute-table.directive.js +++ b/ui/src/app/device/attribute/attribute-table.directive.js @@ -29,7 +29,7 @@ import EditAttributeValueController from './edit-attribute-value.controller'; /*@ngInject*/ export default function AttributeTableDirective($compile, $templateCache, $rootScope, $q, $mdEditDialog, $mdDialog, - $document, $translate, utils, types, dashboardService, deviceService, widgetService) { + $document, $translate, $filter, utils, types, dashboardService, deviceService, widgetService) { var linker = function (scope, element, attrs) { @@ -303,6 +303,9 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS var isSystem = scope.widgetsBundle.tenantId.id === types.id.nullUid; widgetService.getBundleWidgetTypes(scope.widgetsBundle.alias, isSystem).then( function success(widgetTypes) { + + widgetTypes = $filter('orderBy')(widgetTypes, ['-descriptor.type','-createdTime']); + for (var i = 0; i < widgetTypes.length; i++) { var widgetType = widgetTypes[i]; var widgetInfo = widgetService.toWidgetInfo(widgetType); diff --git a/ui/src/app/locale/locale.constant.js b/ui/src/app/locale/locale.constant.js index 525c3079df..05411d77a0 100644 --- a/ui/src/app/locale/locale.constant.js +++ b/ui/src/app/locale/locale.constant.js @@ -67,6 +67,7 @@ export default angular.module('thingsboard.locale', []) "aggregation": "Aggregation", "function": "Data aggregation function", "limit": "Max values", + "group-interval": "Grouping interval", "min": "Min", "max": "Max", "avg": "Average", @@ -558,7 +559,8 @@ export default angular.module('thingsboard.locale', []) "days": "Days", "hours": "Hours", "minutes": "Minutes", - "seconds": "Seconds" + "seconds": "Seconds", + "advanced": "Advanced" }, "timewindow": { "days": "{ days, select, 1 { day } other {# days } }", diff --git a/ui/src/app/widget/lib/flot-widget.js b/ui/src/app/widget/lib/flot-widget.js index c35b363fc8..2888b0fd44 100644 --- a/ui/src/app/widget/lib/flot-widget.js +++ b/ui/src/app/widget/lib/flot-widget.js @@ -167,6 +167,7 @@ export default class TbFlot { var settings = ctx.settings; ctx.trackDecimals = angular.isDefined(settings.decimals) ? settings.decimals : 1; ctx.tooltipIndividual = this.chartType === 'pie' || (angular.isDefined(settings.tooltipIndividual) ? settings.tooltipIndividual : false); + ctx.tooltipCumulative = angular.isDefined(settings.tooltipCumulative) ? settings.tooltipCumulative : false; var font = { color: settings.fontColor || "#545454", @@ -232,6 +233,21 @@ export default class TbFlot { options.yaxis.tickFormatter = function() { return ''; }; + } else if (settings.units && settings.units.length > 0) { + options.yaxis.tickFormatter = function(value, axis) { + var factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1, + formatted = "" + Math.round(value * factor) / factor; + if (axis.tickDecimals != null) { + var decimal = formatted.indexOf("."), + precision = decimal === -1 ? 0 : formatted.length - decimal - 1; + + if (precision < axis.tickDecimals) { + formatted = (precision ? formatted : formatted + ".") + ("" + factor).substr(1, axis.tickDecimals - precision); + } + } + formatted += ' ' + tbFlot.ctx.settings.units; + return formatted; + }; } options.yaxis.font.color = settings.yaxis.color || options.yaxis.font.color; options.yaxis.label = settings.yaxis.title || null; @@ -323,6 +339,8 @@ export default class TbFlot { this.options = options; + this.checkMouseEvents(); + if (this.chartType === 'pie' && this.ctx.animatedPie) { this.ctx.pieDataAnimationDuration = 250; this.ctx.pieData = angular.copy(this.ctx.data); @@ -337,7 +355,6 @@ export default class TbFlot { } else { this.ctx.plot = $.plot(this.ctx.$container, this.ctx.data, this.options); } - this.checkMouseEvents(); } update() { @@ -577,6 +594,11 @@ export default class TbFlot { "type": "boolean", "default": false }, + "tooltipCumulative": { + "title": "Show cumulative values in stacking mode", + "type": "boolean", + "default": false + }, "grid": { "title": "Grid settings", "type": "object", @@ -710,6 +732,7 @@ export default class TbFlot { "decimals", "units", "tooltipIndividual", + "tooltipCumulative", { "key": "grid", "items": [ @@ -834,10 +857,28 @@ export default class TbFlot { } checkMouseEvents() { - if (this.ctx.isMobile || this.ctx.isEdit) { - this.disableMouseEvents(); - } else if (!this.ctx.isEdit) { - this.enableMouseEvents(); + var enabled = !this.ctx.isMobile && !this.ctx.isEdit; + if (angular.isUndefined(this.mouseEventsEnabled) || this.mouseEventsEnabled != enabled) { + this.mouseEventsEnabled = enabled; + if (enabled) { + this.enableMouseEvents(); + } else { + this.disableMouseEvents(); + } + if (this.ctx.plot) { + this.ctx.plot.destroy(); + if (this.chartType === 'pie' && this.ctx.animatedPie) { + this.ctx.plot = $.plot(this.ctx.$container, this.ctx.pieData, this.options); + } else { + this.ctx.plot = $.plot(this.ctx.$container, this.ctx.data, this.options); + } + } + } + } + + destroy() { + if (this.ctx.plot) { + this.ctx.plot.destroy(); } } @@ -1030,7 +1071,7 @@ export default class TbFlot { minTime = pointTime; } if (series.stack) { - if (this.ctx.tooltipIndividual) { + if (this.ctx.tooltipIndividual || !this.ctx.tooltipCumulative) { value = series.data[hoverIndex][1]; } else { last_value += series.data[hoverIndex][1]; diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss index 606b1d9630..31c256d299 100644 --- a/ui/src/scss/main.scss +++ b/ui/src/scss/main.scss @@ -201,6 +201,14 @@ md-sidenav { color: rgba(0,0,0,0.54); } +label { + &.tb-small { + pointer-events: none; + color: rgba(0,0,0,0.54); + font-size: 12px; + } +} + /*********************** * Prompt ***********************/ From 193ef7ddbd3b506d6492c00b5d6cfcf137d30eb7 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 1 Mar 2017 20:02:01 +0200 Subject: [PATCH 12/14] UI: Implement timewindow control on dashboard level --- ui/src/app/api/data-aggregator.js | 10 +- ui/src/app/api/datasource.service.js | 59 +-- ui/src/app/api/time.service.js | 6 +- ui/src/app/components/dashboard.directive.js | 35 +- ui/src/app/components/dashboard.scss | 3 +- ui/src/app/components/dashboard.tpl.html | 35 +- .../components/expand-fullscreen.directive.js | 6 +- .../app/components/timewindow-button.tpl.html | 4 +- ui/src/app/components/timewindow.directive.js | 17 +- ui/src/app/components/timewindow.scss | 7 + ui/src/app/components/timewindow.tpl.html | 22 +- .../app/components/widget-config.directive.js | 7 +- ui/src/app/components/widget-config.tpl.html | 13 +- ui/src/app/components/widget.controller.js | 100 +++- ui/src/app/components/widget.directive.js | 7 +- ui/src/app/components/widget.scss | 4 + .../dashboard-settings.controller.js | 1 + .../app/dashboard/dashboard-settings.tpl.html | 14 +- ui/src/app/dashboard/dashboard.controller.js | 34 +- ui/src/app/dashboard/dashboard.scss | 80 +++ ui/src/app/dashboard/dashboard.tpl.html | 482 +++++++++--------- ui/src/app/locale/locale.constant.js | 6 +- ui/src/app/widget/lib/flot-widget.js | 4 + ui/src/scss/main.scss | 10 + 24 files changed, 631 insertions(+), 335 deletions(-) diff --git a/ui/src/app/api/data-aggregator.js b/ui/src/app/api/data-aggregator.js index 31baff134e..76d3b1cccc 100644 --- a/ui/src/app/api/data-aggregator.js +++ b/ui/src/app/api/data-aggregator.js @@ -74,7 +74,7 @@ export default class DataAggregator { }, this.aggregationTimeout, false); } - onData(data, update, history) { + onData(data, update, history, apply) { if (!this.dataReceived || this.resetPending) { var updateIntervalScheduledTime = true; if (!this.dataReceived) { @@ -96,18 +96,18 @@ export default class DataAggregator { if (updateIntervalScheduledTime) { this.intervalScheduledTime = currentTime(); } - this.onInterval(history); + this.onInterval(history, apply); } else { updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, this.noAggregation, this.aggFunction, data.data, this.interval, this.startTs); if (history) { this.intervalScheduledTime = currentTime(); - this.onInterval(history); + this.onInterval(history, apply); } } } - onInterval(history) { + onInterval(history, apply) { var now = currentTime(); this.elapsed += now - this.intervalScheduledTime; this.intervalScheduledTime = now; @@ -127,7 +127,7 @@ export default class DataAggregator { this.data = toData(this.tsKeyNames, this.aggregationMap, this.startTs, this.endTs, this.$filter, this.limit); } if (this.onDataCb) { - this.onDataCb(this.data, this.startTs, this.endTs); + this.onDataCb(this.data, this.startTs, this.endTs, apply); } var self = this; diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js index 7d2c1be893..5458ada50e 100644 --- a/ui/src/app/api/datasource.service.js +++ b/ui/src/app/api/datasource.service.js @@ -197,7 +197,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var datasourceKey = key + '_' + i; listener.dataUpdated(datasourceData[datasourceKey], listener.datasourceIndex, - dataKey.index); + dataKey.index, false); } } } else { @@ -205,7 +205,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic dataKey = dataKeys[key]; listener.dataUpdated(datasourceData[key], listener.datasourceIndex, - dataKey.index); + dataKey.index, false); } } } @@ -264,7 +264,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic type: types.dataKeyType.timeseries, onData: function (data) { if (data.data) { - onData(data.data, types.dataKeyType.timeseries); + onData(data.data, types.dataKeyType.timeseries, null, null, true); } }, onReconnected: function() {} @@ -287,9 +287,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (datasourceSubscription.type === types.widgetType.timeseries.value) { updateRealtimeSubscriptionCommand(subscriptionCommand, subsTw); - dataAggregator = createRealtimeDataAggregator(subsTw, tsKeyNames); + dataAggregator = createRealtimeDataAggregator(subsTw, tsKeyNames, types.dataKeyType.timeseries); subscriber.onData = function(data) { - dataAggregator.onData(data); + dataAggregator.onData(data, false, false, true); } subscriber.onReconnected = function() { var newSubsTw = null; @@ -308,7 +308,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic subscriber.onReconnected = function() {} subscriber.onData = function(data) { if (data.data) { - onData(data.data, types.dataKeyType.timeseries); + onData(data.data, types.dataKeyType.timeseries, null, null, true); } } } @@ -331,7 +331,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic type: types.dataKeyType.attribute, onData: function (data) { if (data.data) { - onData(data.data, types.dataKeyType.attribute); + onData(data.data, types.dataKeyType.attribute, null, null, true); } }, onReconnected: function() {} @@ -351,33 +351,24 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic tsKeyNames.push(dataKey.name+'_'+dataKey.index); } } - dataAggregator = new DataAggregator( - function (data, startTs, endTs) { - onData(data, types.dataKeyType.function, startTs, endTs); - }, - tsKeyNames, - subsTw.startTs, - subsTw.aggregation.limit, - subsTw.aggregation.type, - subsTw.aggregation.timeWindow, - subsTw.aggregation.interval, - types, - $timeout, - $filter - ); + dataAggregator = createRealtimeDataAggregator(subsTw, tsKeyNames, types.dataKeyType.function); } if (history) { - onTick(); + onTick(false); } else { - timer = $timeout(onTick, 0, false); + timer = $timeout( + function() {onTick(true)}, + 0, + false + ); } } } - function createRealtimeDataAggregator(subsTw, tsKeyNames) { + function createRealtimeDataAggregator(subsTw, tsKeyNames, dataKeyType) { return new DataAggregator( - function(data, startTs, endTs) { - onData(data, types.dataKeyType.timeseries, startTs, endTs); + function(data, startTs, endTs, apply) { + onData(data, dataKeyType, startTs, endTs, apply); }, tsKeyNames, subsTw.startTs, @@ -443,7 +434,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic return data; } - function generateLatest(dataKey) { + function generateLatest(dataKey, apply) { var prevSeries; var datasourceKeyData = datasourceData[dataKey.key].data; if (datasourceKeyData.length > 0) { @@ -461,11 +452,11 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var listener = listeners[i]; listener.dataUpdated(datasourceData[dataKey.key], listener.datasourceIndex, - dataKey.index); + dataKey.index, apply); } } - function onTick() { + function onTick(apply) { var key; if (datasourceSubscription.type === types.widgetType.timeseries.value) { var startTime; @@ -495,15 +486,15 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic generatedData.data[dataKey.name+'_'+dataKey.index] = data; } } - dataAggregator.onData(generatedData, true, history); + dataAggregator.onData(generatedData, true, history, apply); } else if (datasourceSubscription.type === types.widgetType.latest.value) { for (key in dataKeys) { - generateLatest(dataKeys[key]); + generateLatest(dataKeys[key], apply); } } if (!history) { - timer = $timeout(onTick, frequency / 2, false); + timer = $timeout(function() {onTick(true)}, frequency / 2, false); } } @@ -519,7 +510,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } } - function onData(sourceData, type, startTs, endTs) { + function onData(sourceData, type, startTs, endTs, apply) { for (var keyName in sourceData) { var keyData = sourceData[keyName]; var key = keyName + '_' + type; @@ -572,7 +563,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var listener = listeners[i2]; listener.dataUpdated(datasourceData[datasourceKey], listener.datasourceIndex, - dataKey.index); + dataKey.index, apply); } } } diff --git a/ui/src/app/api/time.service.js b/ui/src/app/api/time.service.js index a4bb571a59..14f4c637a9 100644 --- a/ui/src/app/api/time.service.js +++ b/ui/src/app/api/time.service.js @@ -206,9 +206,9 @@ function TimeService($translate, types) { function defaultTimewindow() { var currentTime = (new Date).getTime(); var timewindow = { - displayValue: "", - selectedTab: 0, - realtime: { + displayValue: "", + selectedTab: 0, + realtime: { interval: SECOND, timewindowMs: MINUTE // 1 min by default }, diff --git a/ui/src/app/components/dashboard.directive.js b/ui/src/app/components/dashboard.directive.js index 3889339a23..1141add7ec 100644 --- a/ui/src/app/components/dashboard.directive.js +++ b/ui/src/app/components/dashboard.directive.js @@ -52,6 +52,7 @@ function Dashboard() { bindToController: { widgets: '=', deviceAliasList: '=', + dashboardTimewindow: '=?', columns: '=', margins: '=', isEdit: '=', @@ -71,7 +72,8 @@ function Dashboard() { getStDiff: '&?', onInit: '&?', onInitFailed: '&?', - dashboardStyle: '=?' + dashboardStyle: '=?', + dashboardClass: '=?' }, controller: DashboardController, controllerAs: 'vm', @@ -80,7 +82,7 @@ function Dashboard() { } /*@ngInject*/ -function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $log, toast, types) { +function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, timeService, types) { var highlightedMode = false; var highlightedWidget = null; @@ -99,6 +101,10 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $ vm.isMobileDisabled = angular.isDefined(vm.isMobileDisabled) ? vm.isMobileDisabled : false; + if (!('dashboardTimewindow' in vm)) { + vm.dashboardTimewindow = timeService.defaultTimewindow(); + } + vm.dashboardLoading = true; vm.visibleRect = { top: 0, @@ -176,6 +182,21 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $ vm.widgetContextMenuItems = []; vm.widgetContextMenuEvent = null; + vm.dashboardTimewindowApi = { + onResetTimewindow: function() { + if (vm.originalDashboardTimewindow) { + vm.dashboardTimewindow = angular.copy(vm.originalDashboardTimewindow); + vm.originalDashboardTimewindow = null; + } + }, + onUpdateTimewindow: function(startTimeMs, endTimeMs) { + if (!vm.originalDashboardTimewindow) { + vm.originalDashboardTimewindow = angular.copy(vm.dashboardTimewindow); + } + vm.dashboardTimewindow = timeService.toHistoryTimewindow(vm.dashboardTimewindow, startTimeMs, endTimeMs); + } + }; + //$element[0].onmousemove=function(){ // widgetMouseMove(); // } @@ -656,7 +677,12 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $ } function hasTimewindow(widget) { - return widget.type === types.widgetType.timeseries.value; + if (widget.type === types.widgetType.timeseries.value) { + return angular.isDefined(widget.config.useDashboardTimewindow) ? + !widget.config.useDashboardTimewindow : false; + } else { + return false; + } } function adoptMaxRows() { @@ -673,6 +699,9 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, $ function dashboardLoaded() { $timeout(function () { + $scope.$watch('vm.dashboardTimewindow', function () { + $scope.$broadcast('dashboardTimewindowChanged', vm.dashboardTimewindow); + }, true); adoptMaxRows(); vm.dashboardLoading = false; $timeout(function () { diff --git a/ui/src/app/components/dashboard.scss b/ui/src/app/components/dashboard.scss index 4c4af47639..cf56c15c00 100644 --- a/ui/src/app/components/dashboard.scss +++ b/ui/src/app/components/dashboard.scss @@ -51,7 +51,7 @@ div.tb-widget { height: 32px; min-width: 32px; min-height: 32px; - md-icon { + md-icon, ng-md-icon { width: 20px; height: 20px; min-width: 20px; @@ -93,6 +93,7 @@ md-content.tb-dashboard-content { right: 0; bottom: 0; outline: none; + background: none; .gridster-item { @include transition(none); } diff --git a/ui/src/app/components/dashboard.tpl.html b/ui/src/app/components/dashboard.tpl.html index dc46e35472..114460d3c1 100644 --- a/ui/src/app/components/dashboard.tpl.html +++ b/ui/src/app/components/dashboard.tpl.html @@ -21,7 +21,7 @@ -
+
    @@ -30,6 +30,7 @@
    -
    +
    {{widget.config.title}} - +
    + class="md-icon-button"> {{ 'widget.edit' | translate }} - - edit - + {{ 'widget.export' | translate }} - - file_download - + {{ 'widget.remove' | translate }} - - close - +
    + locals="{ visibleRect: vm.visibleRect, + widget: widget, + deviceAliasList: vm.deviceAliasList, + isEdit: vm.isEdit, + stDiff: vm.stDiff, + dashboardTimewindow: vm.dashboardTimewindow, + dashboardTimewindowApi: vm.dashboardTimewindowApi }">
    diff --git a/ui/src/app/components/expand-fullscreen.directive.js b/ui/src/app/components/expand-fullscreen.directive.js index 16bd71392a..f74aa91795 100644 --- a/ui/src/app/components/expand-fullscreen.directive.js +++ b/ui/src/app/components/expand-fullscreen.directive.js @@ -101,11 +101,15 @@ function ExpandFullscreen($compile, $document) { if (attrs.expandButtonId) { expandButton = $('#' + attrs.expandButtonId, element)[0]; } + var buttonSize; + if (attrs.expandButtonSize) { + buttonSize = attrs.expandButtonSize; + } var html = '' + '{{(expanded ? \'fullscreen.exit\' : \'fullscreen.expand\') | translate}}' + '' + - '' + ''; diff --git a/ui/src/app/components/timewindow-button.tpl.html b/ui/src/app/components/timewindow-button.tpl.html index 11d838295b..cea776c978 100644 --- a/ui/src/app/components/timewindow-button.tpl.html +++ b/ui/src/app/components/timewindow-button.tpl.html @@ -15,7 +15,7 @@ limitations under the License. --> - - date_range + + {{model.displayValue}} \ No newline at end of file diff --git a/ui/src/app/components/timewindow.directive.js b/ui/src/app/components/timewindow.directive.js index 14fff62b47..580b21bae6 100644 --- a/ui/src/app/components/timewindow.directive.js +++ b/ui/src/app/components/timewindow.directive.js @@ -79,26 +79,38 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM if (scope.asButton) { template = $templateCache.get(timewindowButtonTemplate); } else { + scope.direction = scope.direction || 'left'; template = $templateCache.get(timewindowTemplate); } element.html(template); scope.openEditMode = function (event) { + if (scope.disabled) { + return; + } var position; var isGtSm = $mdMedia('gt-sm'); if (isGtSm) { var panelHeight = 375; + var panelWidth = 417; var offset = element[0].getBoundingClientRect(); var bottomY = offset.bottom - $(window).scrollTop(); //eslint-disable-line + var leftX = offset.left - $(window).scrollLeft(); //eslint-disable-line var yPosition; + var xPosition; if (bottomY + panelHeight > $( window ).height()) { //eslint-disable-line yPosition = $mdPanel.yPosition.ABOVE; } else { yPosition = $mdPanel.yPosition.BELOW; } + if (leftX + panelWidth > $( window ).width()) { //eslint-disable-line + xPosition = $mdPanel.xPosition.ALIGN_END; + } else { + xPosition = $mdPanel.xPosition.ALIGN_START; + } position = $mdPanel.newPanelPosition() .relativeTo(element) - .addPanelPosition($mdPanel.xPosition.ALIGN_START, yPosition); + .addPanelPosition(xPosition, yPosition); } else { position = $mdPanel.newPanelPosition() .absolute() @@ -223,7 +235,8 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM require: "^ngModel", scope: { asButton: '=asButton', - buttonColor: '=?' + direction: '=?', + disabled:'=ngDisabled' }, link: linker }; diff --git a/ui/src/app/components/timewindow.scss b/ui/src/app/components/timewindow.scss index 0c85d521e3..9ea7c345a2 100644 --- a/ui/src/app/components/timewindow.scss +++ b/ui/src/app/components/timewindow.scss @@ -57,3 +57,10 @@ } } } + +tb-timewindow { + span { + pointer-events: all; + cursor: pointer; + } +} diff --git a/ui/src/app/components/timewindow.tpl.html b/ui/src/app/components/timewindow.tpl.html index 0de81e2bad..2778975c1c 100644 --- a/ui/src/app/components/timewindow.tpl.html +++ b/ui/src/app/components/timewindow.tpl.html @@ -15,9 +15,23 @@ limitations under the License. --> -
    - {{model.displayValue}} - - date_range +
    + + + {{ 'timewindow.edit' | translate }} + + + + + + {{ 'timewindow.edit' | translate }} + + {{model.displayValue}} + + + + {{ 'timewindow.edit' | translate }} + +
    \ No newline at end of file diff --git a/ui/src/app/components/widget-config.directive.js b/ui/src/app/components/widget-config.directive.js index c2c5743611..639395a64a 100644 --- a/ui/src/app/components/widget-config.directive.js +++ b/ui/src/app/components/widget-config.directive.js @@ -98,6 +98,8 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti }, true); scope.mobileOrder = ngModelCtrl.$viewValue.mobileOrder; scope.mobileHeight = ngModelCtrl.$viewValue.mobileHeight; + scope.useDashboardTimewindow = angular.isDefined(ngModelCtrl.$viewValue.useDashboardTimewindow) ? + ngModelCtrl.$viewValue.useDashboardTimewindow : true; scope.timewindow = ngModelCtrl.$viewValue.timewindow; if (scope.widgetType !== types.widgetType.rpc.value && scope.widgetType !== types.widgetType.static.value) { if (scope.datasources) { @@ -174,7 +176,8 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti } }; - scope.$watch('title + showTitle + dropShadow + enableFullscreen + backgroundColor + color + padding + titleStyle + mobileOrder + mobileHeight + intervalSec', function () { + scope.$watch('title + showTitle + dropShadow + enableFullscreen + backgroundColor + color + ' + + 'padding + titleStyle + mobileOrder + mobileHeight + useDashboardTimewindow', function () { if (ngModelCtrl.$viewValue) { var value = ngModelCtrl.$viewValue; value.title = scope.title; @@ -191,7 +194,7 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti } value.mobileOrder = angular.isNumber(scope.mobileOrder) ? scope.mobileOrder : undefined; value.mobileHeight = scope.mobileHeight; - value.intervalSec = scope.intervalSec; + value.useDashboardTimewindow = scope.useDashboardTimewindow; ngModelCtrl.$setViewValue(value); scope.updateValidity(); } diff --git a/ui/src/app/components/widget-config.tpl.html b/ui/src/app/components/widget-config.tpl.html index 23c64fcc5f..88bfc2d5fb 100644 --- a/ui/src/app/components/widget-config.tpl.html +++ b/ui/src/app/components/widget-config.tpl.html @@ -88,10 +88,15 @@
    -
    - widget-config.timewindow - +
    + {{ 'widget-config.use-dashboard-timewindow' | translate }} + +
    + widget-config.timewindow + +
    diff --git a/ui/src/app/components/widget.controller.js b/ui/src/app/components/widget.controller.js index a2056e0b36..c0617bc12a 100644 --- a/ui/src/app/components/widget.controller.js +++ b/ui/src/app/components/widget.controller.js @@ -20,7 +20,8 @@ import 'javascript-detect-element-resize/detect-element-resize'; /*@ngInject*/ export default function WidgetController($scope, $timeout, $window, $element, $q, $log, $injector, tbRaf, types, utils, timeService, - datasourceService, deviceService, visibleRect, isEdit, stDiff, widget, deviceAliasList, widgetType) { + datasourceService, deviceService, visibleRect, isEdit, stDiff, dashboardTimewindow, + dashboardTimewindowApi, widget, deviceAliasList, widgetType) { var vm = this; @@ -136,6 +137,24 @@ export default function WidgetController($scope, $timeout, $window, $element, $q $scope.widgetErrorData = utils.processWidgetException(e); } + function notifyDataLoaded(apply) { + if ($scope.loadingData === true) { + $scope.loadingData = false; + if (apply) { + $scope.$digest(); + } + } + } + + function notifyDataLoading(apply) { + if ($scope.loadingData === false) { + $scope.loadingData = true; + if (apply) { + $scope.$digest(); + } + } + } + function onInit() { if (!widgetContext.inited) { widgetContext.inited = true; @@ -274,7 +293,7 @@ export default function WidgetController($scope, $timeout, $window, $element, $q } function initialize() { - if (widget.type !== types.widgetType.rpc.value) { + if (widget.type !== types.widgetType.rpc.value && widget.type !== types.widgetType.static.value) { for (var i in widget.config.datasources) { var datasource = angular.copy(widget.config.datasources[i]); for (var a in datasource.dataKeys) { @@ -287,7 +306,7 @@ export default function WidgetController($scope, $timeout, $window, $element, $q widgetContext.data.push(datasourceData); } } - } else { + } else if (widget.type === types.widgetType.rpc.value) { if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length > 0) { targetDeviceAliasId = widget.config.targetDeviceAliasIds[0]; if (deviceAliasList[targetDeviceAliasId]) { @@ -354,14 +373,26 @@ export default function WidgetController($scope, $timeout, $window, $element, $q }); if (widget.type === types.widgetType.timeseries.value) { - $scope.$watch(function () { - return widget.config.timewindow; - }, function (newTimewindow, prevTimewindow) { - if (!angular.equals(newTimewindow, prevTimewindow)) { - unsubscribe(); - subscribe(); - } - }); + widgetContext.useDashboardTimewindow = angular.isDefined(widget.config.useDashboardTimewindow) + ? widget.config.useDashboardTimewindow : true; + if (widgetContext.useDashboardTimewindow) { + $scope.$on('dashboardTimewindowChanged', function (event, newDashboardTimewindow) { + if (!angular.equals(dashboardTimewindow, newDashboardTimewindow)) { + dashboardTimewindow = newDashboardTimewindow; + unsubscribe(); + subscribe(); + } + }); + } else { + $scope.$watch(function () { + return widgetContext.useDashboardTimewindow ? dashboardTimewindow : widget.config.timewindow; + }, function (newTimewindow, prevTimewindow) { + if (!angular.equals(newTimewindow, prevTimewindow)) { + unsubscribe(); + subscribe(); + } + }); + } } subscribe(); } @@ -474,20 +505,29 @@ export default function WidgetController($scope, $timeout, $window, $element, $q }*/ function onResetTimewindow() { - if (originalTimewindow) { - widget.config.timewindow = angular.copy(originalTimewindow); - originalTimewindow = null; + if (widgetContext.useDashboardTimewindow) { + dashboardTimewindowApi.onResetTimewindow(); + } else { + if (originalTimewindow) { + widget.config.timewindow = angular.copy(originalTimewindow); + originalTimewindow = null; + } } } function onUpdateTimewindow(startTimeMs, endTimeMs) { - if (!originalTimewindow) { - originalTimewindow = angular.copy(widget.config.timewindow); + if (widgetContext.useDashboardTimewindow) { + dashboardTimewindowApi.onUpdateTimewindow(startTimeMs, endTimeMs); + } else { + if (!originalTimewindow) { + originalTimewindow = angular.copy(widget.config.timewindow); + } + widget.config.timewindow = timeService.toHistoryTimewindow(widget.config.timewindow, startTimeMs, endTimeMs); } - widget.config.timewindow = timeService.toHistoryTimewindow(widget.config.timewindow, startTimeMs, endTimeMs); } - function dataUpdated(sourceData, datasourceIndex, dataKeyIndex) { + function dataUpdated(sourceData, datasourceIndex, dataKeyIndex, apply) { + notifyDataLoaded(apply); var update = true; if (widget.type === types.widgetType.latest.value) { var prevData = widgetContext.data[datasourceIndex + dataKeyIndex].data; @@ -547,16 +587,28 @@ export default function WidgetController($scope, $timeout, $window, $element, $q if (_subscriptionTimewindow) { subscriptionTimewindow = _subscriptionTimewindow; } else { - subscriptionTimewindow = timeService.createSubscriptionTimewindow(widget.config.timewindow, widgetContext.timeWindow.stDiff); + subscriptionTimewindow = + timeService.createSubscriptionTimewindow( + widgetContext.useDashboardTimewindow ? dashboardTimewindow : widget.config.timewindow, + widgetContext.timeWindow.stDiff); } updateTimewindow(); return subscriptionTimewindow; } + function hasTimewindow() { + if (widgetContext.useDashboardTimewindow) { + return angular.isDefined(dashboardTimewindow); + } else { + return angular.isDefined(widget.config.timewindow); + } + } + function subscribe() { - if (widget.type !== types.widgetType.rpc.value) { + if (widget.type !== types.widgetType.rpc.value && widget.type !== types.widgetType.static.value) { + notifyDataLoading(); if (widget.type === types.widgetType.timeseries.value && - angular.isDefined(widget.config.timewindow)) { + hasTimewindow()) { updateRealtimeSubscription(); if (subscriptionTimewindow.fixedWindow) { onDataUpdated(); @@ -579,8 +631,8 @@ export default function WidgetController($scope, $timeout, $window, $element, $q subscriptionTimewindow: subscriptionTimewindow, datasource: datasource, deviceId: deviceId, - dataUpdated: function (data, datasourceIndex, dataKeyIndex) { - dataUpdated(data, datasourceIndex, dataKeyIndex); + dataUpdated: function (data, datasourceIndex, dataKeyIndex, apply) { + dataUpdated(data, datasourceIndex, dataKeyIndex, apply); }, updateRealtimeSubscription: function() { this.subscriptionTimewindow = updateRealtimeSubscription(); @@ -601,6 +653,8 @@ export default function WidgetController($scope, $timeout, $window, $element, $q datasourceListeners.push(listener); datasourceService.subscribeToDatasource(listener); } + } else { + notifyDataLoaded(); } } diff --git a/ui/src/app/components/widget.directive.js b/ui/src/app/components/widget.directive.js index cd1d16bcf6..6d3a33073d 100644 --- a/ui/src/app/components/widget.directive.js +++ b/ui/src/app/components/widget.directive.js @@ -66,6 +66,8 @@ function Widget($controller, $compile, widgetService) { function loadFromWidgetInfo(widgetInfo) { + scope.loadingData = true; + elem.addClass("tb-widget"); var widgetNamespace = "widget-type-" + (widget.isSystemType ? 'sys-' : '') @@ -73,9 +75,12 @@ function Widget($controller, $compile, widgetService) { + widget.typeAlias; elem.addClass(widgetNamespace); - elem.html('
    ' + + elem.html('
    ' + 'Widget Error: {{ widgetErrorData.name + ": " + widgetErrorData.message}}' + '
    ' + + '
    ' + + '' + + '
    ' + '
    ' + widgetInfo.templateHtml + '
    '); $compile(elem.contents())(scope); diff --git a/ui/src/app/components/widget.scss b/ui/src/app/components/widget.scss index bad71b91fc..ddeddcc83b 100644 --- a/ui/src/app/components/widget.scss +++ b/ui/src/app/components/widget.scss @@ -23,4 +23,8 @@ color: red; } } + .tb-widget-loading { + background: rgba(255,255,255,0.15); + z-index: 3; + } } diff --git a/ui/src/app/dashboard/dashboard-settings.controller.js b/ui/src/app/dashboard/dashboard-settings.controller.js index 91885d3889..aac6da3dd0 100644 --- a/ui/src/app/dashboard/dashboard-settings.controller.js +++ b/ui/src/app/dashboard/dashboard-settings.controller.js @@ -32,6 +32,7 @@ export default function DashboardSettingsController($scope, $mdDialog, gridSetti } vm.gridSettings.backgroundColor = vm.gridSettings.backgroundColor || 'rgba(0,0,0,0)'; + vm.gridSettings.titleColor = vm.gridSettings.titleColor || 'rgba(0,0,0,0.870588)'; vm.gridSettings.columns = vm.gridSettings.columns || 24; vm.gridSettings.margins = vm.gridSettings.margins || [10, 10]; vm.hMargin = vm.gridSettings.margins[0]; diff --git a/ui/src/app/dashboard/dashboard-settings.tpl.html b/ui/src/app/dashboard/dashboard-settings.tpl.html index 9a1f5d4cd1..38cb530f27 100644 --- a/ui/src/app/dashboard/dashboard-settings.tpl.html +++ b/ui/src/app/dashboard/dashboard-settings.tpl.html @@ -31,10 +31,22 @@
    -
    +
    {{ 'dashboard.display-title' | translate }} +
    diff --git a/ui/src/app/dashboard/dashboard.controller.js b/ui/src/app/dashboard/dashboard.controller.js index c2a9cf316a..167b5ae594 100644 --- a/ui/src/app/dashboard/dashboard.controller.js +++ b/ui/src/app/dashboard/dashboard.controller.js @@ -23,7 +23,7 @@ import addWidgetTemplate from './add-widget.tpl.html'; /*@ngInject*/ export default function DashboardController(types, widgetService, userService, - dashboardService, itembuffer, importExport, hotkeys, $window, $rootScope, + dashboardService, timeService, itembuffer, importExport, hotkeys, $window, $rootScope, $scope, $state, $stateParams, $mdDialog, $timeout, $document, $q, $translate, $filter) { var user = userService.getCurrentUser(); @@ -47,6 +47,25 @@ export default function DashboardController(types, widgetService, userService, vm.widgets = []; vm.dashboardInitComplete = false; + vm.isToolbarOpened = false; + + Object.defineProperty(vm, 'toolbarOpened', { + get: function() { return vm.isToolbarOpened || vm.isEdit; }, + set: function() { } + }); + + vm.openToolbar = function() { + $timeout(function() { + vm.isToolbarOpened = true; + }); + } + + vm.closeToolbar = function() { + $timeout(function() { + vm.isToolbarOpened = false; + }); + } + vm.addWidget = addWidget; vm.addWidgetFromType = addWidgetFromType; vm.dashboardInited = dashboardInited; @@ -154,6 +173,9 @@ export default function DashboardController(types, widgetService, userService, if (vm.widgetEditMode) { $timeout(function () { + vm.dashboardConfiguration = { + timewindow: timeService.defaultTimewindow() + }; vm.widgets = [{ isSystemType: true, bundleAlias: 'customWidgetBundle', @@ -186,9 +208,12 @@ export default function DashboardController(types, widgetService, userService, if (angular.isUndefined(vm.dashboard.configuration.deviceAliases)) { vm.dashboard.configuration.deviceAliases = {}; } - //$timeout(function () { - vm.widgets = vm.dashboard.configuration.widgets; - //}); + + if (angular.isUndefined(vm.dashboard.configuration.timewindow)) { + vm.dashboard.configuration.timewindow = timeService.defaultTimewindow(); + } + vm.dashboardConfiguration = vm.dashboard.configuration; + vm.widgets = vm.dashboard.configuration.widgets; deferred.resolve(); }, function fail(e) { deferred.reject(e); @@ -607,6 +632,7 @@ export default function DashboardController(types, widgetService, userService, if (revert) { vm.dashboard = vm.prevDashboard; vm.widgets = vm.dashboard.configuration.widgets; + vm.dashboardConfiguration = vm.dashboard.configuration; } } } diff --git a/ui/src/app/dashboard/dashboard.scss b/ui/src/app/dashboard/dashboard.scss index 64aa4ec090..2d3b2fa5c0 100644 --- a/ui/src/app/dashboard/dashboard.scss +++ b/ui/src/app/dashboard/dashboard.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +@import "~compass-sass-mixins/lib/compass"; @import '../../scss/constants'; section.tb-dashboard-title { @@ -53,3 +55,81 @@ tb-details-sidenav.tb-widget-details-sidenav { } } } + +/*********************** + * dashboard toolbar + ***********************/ + +section.tb-dashboard-toolbar { + position: absolute; + top: 0px; + left: -100%; + z-index: 3; + pointer-events: none; + &.tb-dashboard-toolbar-opened { + right: 0px; + @include transition(right .3s cubic-bezier(.55,0,.55,.2)); + } + &.tb-dashboard-toolbar-closed { + right: 18px; + @include transition(right .3s cubic-bezier(.55,0,.55,.2) .2s); + } + md-fab-toolbar { + &.md-is-open { + md-fab-trigger { + .md-button { + &.md-fab { + opacity: 1; + @include transition(opacity .3s cubic-bezier(.55,0,.55,.2)); + } + } + } + } + md-fab-trigger { + .md-button { + &.md-fab { + line-height: 36px; + width: 36px; + height: 36px; + margin: 4px 0 0 4px; + opacity: 0.5; + @include transition(opacity .3s cubic-bezier(.55,0,.55,.2) .2s); + md-icon { + margin: 0; + line-height: 18px; + height: 18px; + width: 18px; + min-height: 18px; + min-width: 18px; + } + } + } + } + .md-fab-toolbar-wrapper { + height: 40px; + md-toolbar { + min-height: 36px; + height: 36px; + md-fab-actions { + .close-action { + margin-right: -18px; + } + tb-timewindow { + font-size: 16px; + } + } + } + } + } +} + +.tb-dashboard-container { + &.tb-dashboard-toolbar-opened { + margin-top: 40px; + @include transition(margin-top .3s cubic-bezier(.55,0,.55,.2)); + } + &.tb-dashboard-toolbar-closed { + margin-top: 0px; + @include transition(margin-top .3s cubic-bezier(.55,0,.55,.2) .2s); + } +} diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html index 298d365bb8..bf0b3ac1e5 100644 --- a/ui/src/app/dashboard/dashboard.tpl.html +++ b/ui/src/app/dashboard/dashboard.tpl.html @@ -15,235 +15,263 @@ limitations under the License. --> - - -
    - - dashboard.no-widgets - - - add - {{ 'dashboard.add-widget' | translate }} - -
    -
    -

    {{ vm.dashboard.title }}

    - - - - - - {{ 'device.aliases' | translate }} - - - {{ 'dashboard.settings' | translate }} - -
    -
    - - -
    - - -
    -
    -
    - - -
    -
    - - -
    - {{ 'widgets-bundle.current' | translate }} - - -
    -
    -
    - - - - - - - - - - - - - - - - - - - widgets-bundle.empty - widget.select-widgets-bundle -
    -
    - -