Commit e4963de151f660b07f3fcc5296e5191e7926a67e
1 parent
93250456
UI: Add timeseries aggregation support
Showing
16 changed files
with
480 additions
and
84 deletions
... | ... | @@ -53,8 +53,9 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.select; |
53 | 53 | @Slf4j |
54 | 54 | public class BaseTimeseriesDao extends AbstractAsyncDao implements TimeseriesDao { |
55 | 55 | |
56 | - @Value("${cassandra.query.min_aggregation_step_ms}") | |
57 | - private int minAggregationStepMs; | |
56 | + //@Value("${cassandra.query.min_aggregation_step_ms}") | |
57 | + //TODO: | |
58 | + private int minAggregationStepMs = 1000; | |
58 | 59 | |
59 | 60 | @Value("${cassandra.query.ts_key_value_partitioning}") |
60 | 61 | private String partitioning; | ... | ... |
... | ... | @@ -234,7 +234,7 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler { |
234 | 234 | return new PluginCallback<List<TsKvEntry>>() { |
235 | 235 | @Override |
236 | 236 | public void onSuccess(PluginContext ctx, List<TsKvEntry> data) { |
237 | - sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), data)); | |
237 | + sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), startTs, data)); | |
238 | 238 | |
239 | 239 | Map<String, Long> subState = new HashMap<>(keys.size()); |
240 | 240 | keys.forEach(key -> subState.put(key, startTs)); | ... | ... |
... | ... | @@ -26,10 +26,16 @@ public class SubscriptionUpdate { |
26 | 26 | private int errorCode; |
27 | 27 | private String errorMsg; |
28 | 28 | private Map<String, List<Object>> data; |
29 | + private long serverStartTs; | |
29 | 30 | |
30 | 31 | public SubscriptionUpdate(int subscriptionId, List<TsKvEntry> data) { |
32 | + this(subscriptionId, 0L, data); | |
33 | + } | |
34 | + | |
35 | + public SubscriptionUpdate(int subscriptionId, long serverStartTs, List<TsKvEntry> data) { | |
31 | 36 | super(); |
32 | 37 | this.subscriptionId = subscriptionId; |
38 | + this.serverStartTs = serverStartTs; | |
33 | 39 | this.data = new TreeMap<>(); |
34 | 40 | for (TsKvEntry tsEntry : data) { |
35 | 41 | List<Object> values = this.data.get(tsEntry.getKey()); |
... | ... | @@ -89,9 +95,13 @@ public class SubscriptionUpdate { |
89 | 95 | return errorMsg; |
90 | 96 | } |
91 | 97 | |
98 | + public long getServerStartTs() { | |
99 | + return serverStartTs; | |
100 | + } | |
101 | + | |
92 | 102 | @Override |
93 | 103 | public String toString() { |
94 | 104 | return "SubscriptionUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", data=" |
95 | - + data + "]"; | |
105 | + + data + ", serverStartTs=" + serverStartTs+ "]"; | |
96 | 106 | } |
97 | 107 | } | ... | ... |
ui/src/app/api/data-aggregator.js
0 → 100644
1 | +/* | |
2 | + * Copyright © 2016-2017 The Thingsboard Authors | |
3 | + * | |
4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | |
5 | + * you may not use this file except in compliance with the License. | |
6 | + * You may obtain a copy of the License at | |
7 | + * | |
8 | + * http://www.apache.org/licenses/LICENSE-2.0 | |
9 | + * | |
10 | + * Unless required by applicable law or agreed to in writing, software | |
11 | + * distributed under the License is distributed on an "AS IS" BASIS, | |
12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
13 | + * See the License for the specific language governing permissions and | |
14 | + * limitations under the License. | |
15 | + */ | |
16 | + | |
17 | +export default class DataAggregator { | |
18 | + | |
19 | + constructor(onDataCb, limit, aggregationType, timeWindow, types, $timeout, $filter) { | |
20 | + this.onDataCb = onDataCb; | |
21 | + this.aggregationType = aggregationType; | |
22 | + this.types = types; | |
23 | + this.$timeout = $timeout; | |
24 | + this.$filter = $filter; | |
25 | + this.dataReceived = false; | |
26 | + this.noAggregation = aggregationType === types.aggregation.none.value; | |
27 | + var interval = Math.floor(timeWindow / limit); | |
28 | + if (!this.noAggregation) { | |
29 | + this.interval = Math.max(interval, 1000); | |
30 | + this.limit = Math.ceil(interval/this.interval * limit); | |
31 | + this.timeWindow = this.interval * this.limit; | |
32 | + } else { | |
33 | + this.limit = limit; | |
34 | + this.timeWindow = interval * this.limit; | |
35 | + this.interval = 1000; | |
36 | + } | |
37 | + this.aggregationTimeout = this.interval; | |
38 | + switch (aggregationType) { | |
39 | + case types.aggregation.min.value: | |
40 | + this.aggFunction = min; | |
41 | + break; | |
42 | + case types.aggregation.max.value: | |
43 | + this.aggFunction = max | |
44 | + break; | |
45 | + case types.aggregation.avg.value: | |
46 | + this.aggFunction = avg; | |
47 | + break; | |
48 | + case types.aggregation.sum.value: | |
49 | + this.aggFunction = sum; | |
50 | + break; | |
51 | + case types.aggregation.count.value: | |
52 | + this.aggFunction = count; | |
53 | + break; | |
54 | + case types.aggregation.none.value: | |
55 | + this.aggFunction = none; | |
56 | + break; | |
57 | + default: | |
58 | + this.aggFunction = avg; | |
59 | + } | |
60 | + } | |
61 | + | |
62 | + onData(data) { | |
63 | + if (!this.dataReceived) { | |
64 | + this.elapsed = 0; | |
65 | + this.dataReceived = true; | |
66 | + this.startTs = data.serverStartTs; | |
67 | + this.endTs = this.startTs + this.timeWindow; | |
68 | + this.aggregationMap = processAggregatedData(data.data, this.aggregationType === this.types.aggregation.count.value, this.noAggregation); | |
69 | + this.onInterval(currentTime()); | |
70 | + } else { | |
71 | + updateAggregatedData(this.aggregationMap, this.aggregationType === this.types.aggregation.count.value, | |
72 | + this.noAggregation, this.aggFunction, data.data, this.interval, this.startTs); | |
73 | + } | |
74 | + } | |
75 | + | |
76 | + onInterval(startedTime) { | |
77 | + var now = currentTime(); | |
78 | + this.elapsed += now - startedTime; | |
79 | + if (this.intervalTimeoutHandle) { | |
80 | + this.$timeout.cancel(this.intervalTimeoutHandle); | |
81 | + this.intervalTimeoutHandle = null; | |
82 | + } | |
83 | + var delta = Math.floor(this.elapsed / this.interval); | |
84 | + if (delta || !this.data) { | |
85 | + this.startTs += delta * this.interval; | |
86 | + this.endTs += delta * this.interval; | |
87 | + this.data = toData(this.aggregationMap, this.startTs, this.endTs, this.$filter, this.limit); | |
88 | + this.elapsed = this.elapsed - delta * this.interval; | |
89 | + } | |
90 | + if (this.onDataCb) { | |
91 | + this.onDataCb(this.data, this.startTs, this.endTs); | |
92 | + } | |
93 | + | |
94 | + var self = this; | |
95 | + this.intervalTimeoutHandle = this.$timeout(function() { | |
96 | + self.onInterval(now); | |
97 | + }, this.aggregationTimeout, false); | |
98 | + } | |
99 | + | |
100 | + reset() { | |
101 | + this.destroy(); | |
102 | + this.dataReceived = false; | |
103 | + } | |
104 | + | |
105 | + destroy() { | |
106 | + if (this.intervalTimeoutHandle) { | |
107 | + this.$timeout.cancel(this.intervalTimeoutHandle); | |
108 | + this.intervalTimeoutHandle = null; | |
109 | + } | |
110 | + this.aggregationMap = null; | |
111 | + } | |
112 | + | |
113 | +} | |
114 | + | |
115 | +/* eslint-disable */ | |
116 | +function currentTime() { | |
117 | + return window.performance && window.performance.now ? | |
118 | + window.performance.now() : Date.now(); | |
119 | +} | |
120 | +/* eslint-enable */ | |
121 | + | |
122 | +function processAggregatedData(data, isCount, noAggregation) { | |
123 | + var aggregationMap = {}; | |
124 | + for (var key in data) { | |
125 | + var aggKeyData = aggregationMap[key]; | |
126 | + if (!aggKeyData) { | |
127 | + aggKeyData = {}; | |
128 | + aggregationMap[key] = aggKeyData; | |
129 | + } | |
130 | + var keyData = data[key]; | |
131 | + for (var i in keyData) { | |
132 | + var kvPair = keyData[i]; | |
133 | + var timestamp = kvPair[0]; | |
134 | + var value = convertValue(kvPair[1], noAggregation); | |
135 | + var aggKey = timestamp; | |
136 | + var aggData = { | |
137 | + count: isCount ? value : 1, | |
138 | + sum: value, | |
139 | + aggValue: value | |
140 | + } | |
141 | + aggKeyData[aggKey] = aggData; | |
142 | + } | |
143 | + } | |
144 | + return aggregationMap; | |
145 | +} | |
146 | + | |
147 | +function updateAggregatedData(aggregationMap, isCount, noAggregation, aggFunction, data, interval, startTs) { | |
148 | + for (var key in data) { | |
149 | + var aggKeyData = aggregationMap[key]; | |
150 | + if (!aggKeyData) { | |
151 | + aggKeyData = {}; | |
152 | + aggregationMap[key] = aggKeyData; | |
153 | + } | |
154 | + var keyData = data[key]; | |
155 | + for (var i in keyData) { | |
156 | + var kvPair = keyData[i]; | |
157 | + var timestamp = kvPair[0]; | |
158 | + var value = convertValue(kvPair[1], noAggregation); | |
159 | + var aggTimestamp = noAggregation ? timestamp : (startTs + Math.floor((timestamp - startTs) / interval) * interval + interval/2); | |
160 | + var aggData = aggKeyData[aggTimestamp]; | |
161 | + if (!aggData) { | |
162 | + aggData = { | |
163 | + count: 1, | |
164 | + sum: value, | |
165 | + aggValue: isCount ? 1 : value | |
166 | + } | |
167 | + aggKeyData[aggTimestamp] = aggData; | |
168 | + } else { | |
169 | + aggFunction(aggData, value); | |
170 | + } | |
171 | + } | |
172 | + } | |
173 | +} | |
174 | + | |
175 | +function toData(aggregationMap, startTs, endTs, $filter, limit) { | |
176 | + var data = {}; | |
177 | + for (var key in aggregationMap) { | |
178 | + if (!data[key]) { | |
179 | + data[key] = []; | |
180 | + } | |
181 | + var aggKeyData = aggregationMap[key]; | |
182 | + var keyData = data[key]; | |
183 | + for (var aggTimestamp in aggKeyData) { | |
184 | + if (aggTimestamp <= startTs) { | |
185 | + delete aggKeyData[aggTimestamp]; | |
186 | + } else if (aggTimestamp <= endTs) { | |
187 | + var aggData = aggKeyData[aggTimestamp]; | |
188 | + var kvPair = [aggTimestamp, aggData.aggValue]; | |
189 | + keyData.push(kvPair); | |
190 | + } | |
191 | + } | |
192 | + keyData = $filter('orderBy')(keyData, '+this[0]'); | |
193 | + if (keyData.length > limit) { | |
194 | + keyData = keyData.slice(keyData.length - limit); | |
195 | + } | |
196 | + data[key] = keyData; | |
197 | + } | |
198 | + return data; | |
199 | +} | |
200 | + | |
201 | +function convertValue(value, noAggregation) { | |
202 | + if (!noAggregation || value && isNumeric(value)) { | |
203 | + return Number(value); | |
204 | + } else { | |
205 | + return value; | |
206 | + } | |
207 | +} | |
208 | + | |
209 | +function isNumeric(value) { | |
210 | + return (value - parseFloat( value ) + 1) >= 0; | |
211 | +} | |
212 | + | |
213 | +function avg(aggData, value) { | |
214 | + aggData.count++; | |
215 | + aggData.sum += value; | |
216 | + aggData.aggValue = aggData.sum / aggData.count; | |
217 | +} | |
218 | + | |
219 | +function min(aggData, value) { | |
220 | + aggData.aggValue = Math.min(aggData.aggValue, value); | |
221 | +} | |
222 | + | |
223 | +function max(aggData, value) { | |
224 | + aggData.aggValue = Math.max(aggData.aggValue, value); | |
225 | +} | |
226 | + | |
227 | +function sum(aggData, value) { | |
228 | + aggData.aggValue = aggData.aggValue + value; | |
229 | +} | |
230 | + | |
231 | +function count(aggData) { | |
232 | + aggData.count++; | |
233 | + aggData.aggValue = aggData.count; | |
234 | +} | |
235 | + | |
236 | +function none(aggData, value) { | |
237 | + aggData.aggValue = value; | |
238 | +} | ... | ... |
... | ... | @@ -17,13 +17,14 @@ import thingsboardApiDevice from './device.service'; |
17 | 17 | import thingsboardApiTelemetryWebsocket from './telemetry-websocket.service'; |
18 | 18 | import thingsboardTypes from '../common/types.constant'; |
19 | 19 | import thingsboardUtils from '../common/utils.service'; |
20 | +import DataAggregator from './data-aggregator'; | |
20 | 21 | |
21 | 22 | export default angular.module('thingsboard.api.datasource', [thingsboardApiDevice, thingsboardApiTelemetryWebsocket, thingsboardTypes, thingsboardUtils]) |
22 | 23 | .factory('datasourceService', DatasourceService) |
23 | 24 | .name; |
24 | 25 | |
25 | 26 | /*@ngInject*/ |
26 | -function DatasourceService($timeout, $log, telemetryWebsocketService, types, utils) { | |
27 | +function DatasourceService($timeout, $filter, $log, telemetryWebsocketService, types, utils) { | |
27 | 28 | |
28 | 29 | var subscriptions = {}; |
29 | 30 | |
... | ... | @@ -73,7 +74,7 @@ function DatasourceService($timeout, $log, telemetryWebsocketService, types, uti |
73 | 74 | subscription = subscriptions[listener.datasourceSubscriptionKey]; |
74 | 75 | subscription.syncListener(listener); |
75 | 76 | } else { |
76 | - subscription = new DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $log, types, utils); | |
77 | + subscription = new DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $filter, $log, types, utils); | |
77 | 78 | subscriptions[listener.datasourceSubscriptionKey] = subscription; |
78 | 79 | subscription.start(); |
79 | 80 | } |
... | ... | @@ -96,7 +97,7 @@ function DatasourceService($timeout, $log, telemetryWebsocketService, types, uti |
96 | 97 | |
97 | 98 | } |
98 | 99 | |
99 | -function DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $log, types, utils) { | |
100 | +function DatasourceSubscription(datasourceSubscription, telemetryWebsocketService, $timeout, $filter, $log, types, utils) { | |
100 | 101 | |
101 | 102 | var listeners = []; |
102 | 103 | var datasourceType = datasourceSubscription.datasourceType; |
... | ... | @@ -134,7 +135,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
134 | 135 | if (!dataKey.func) { |
135 | 136 | dataKey.func = new Function("time", "prevValue", dataKey.funcBody); |
136 | 137 | } |
137 | - datasourceData[key] = []; | |
138 | + datasourceData[key] = { | |
139 | + data: [] | |
140 | + }; | |
138 | 141 | dataKeys[key] = dataKey; |
139 | 142 | } else if (datasourceType === types.datasourceType.device) { |
140 | 143 | key = dataKey.name + '_' + dataKey.type; |
... | ... | @@ -147,7 +150,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
147 | 150 | dataKeys[key] = dataKeysList; |
148 | 151 | } |
149 | 152 | var index = dataKeysList.push(dataKey) - 1; |
150 | - datasourceData[key + '_' + index] = []; | |
153 | + datasourceData[key + '_' + index] = { | |
154 | + data: [] | |
155 | + }; | |
151 | 156 | } |
152 | 157 | dataKey.key = key; |
153 | 158 | } |
... | ... | @@ -248,14 +253,18 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
248 | 253 | deviceId: datasourceSubscription.deviceId, |
249 | 254 | keys: tsKeys, |
250 | 255 | startTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.startTimeMs, |
251 | - endTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs | |
256 | + endTs: datasourceSubscription.subscriptionTimewindow.fixedWindow.endTimeMs, | |
257 | + limit: datasourceSubscription.subscriptionTimewindow.aggregation.limit, | |
258 | + agg: datasourceSubscription.subscriptionTimewindow.aggregation.type | |
252 | 259 | }; |
253 | 260 | |
254 | 261 | subscriber = { |
255 | 262 | historyCommand: historyCommand, |
256 | 263 | type: types.dataKeyType.timeseries, |
257 | 264 | onData: function (data) { |
258 | - onData(data, types.dataKeyType.timeseries); | |
265 | + if (data.data) { | |
266 | + onData(data.data, types.dataKeyType.timeseries); | |
267 | + } | |
259 | 268 | }, |
260 | 269 | onReconnected: function() { |
261 | 270 | onReconnected(); |
... | ... | @@ -272,20 +281,46 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
272 | 281 | keys: tsKeys |
273 | 282 | }; |
274 | 283 | |
275 | - if (datasourceSubscription.type === types.widgetType.timeseries.value) { | |
276 | - subscriptionCommand.timeWindow = datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; | |
277 | - } | |
278 | - | |
279 | 284 | subscriber = { |
280 | 285 | subscriptionCommand: subscriptionCommand, |
281 | - type: types.dataKeyType.timeseries, | |
282 | - onData: function (data) { | |
283 | - onData(data, types.dataKeyType.timeseries); | |
284 | - }, | |
285 | - onReconnected: function() { | |
286 | + type: types.dataKeyType.timeseries | |
287 | + }; | |
288 | + | |
289 | + if (datasourceSubscription.type === types.widgetType.timeseries.value) { | |
290 | + subscriptionCommand.timeWindow = datasourceSubscription.subscriptionTimewindow.realtimeWindowMs; | |
291 | + subscriptionCommand.limit = datasourceSubscription.subscriptionTimewindow.aggregation.limit; | |
292 | + subscriptionCommand.agg = datasourceSubscription.subscriptionTimewindow.aggregation.type; | |
293 | + var dataAggregator = new DataAggregator( | |
294 | + function(data, startTs, endTs) { | |
295 | + onData(data, types.dataKeyType.timeseries, startTs, endTs); | |
296 | + }, | |
297 | + subscriptionCommand.limit, | |
298 | + subscriptionCommand.agg, | |
299 | + subscriptionCommand.timeWindow, | |
300 | + types, | |
301 | + $timeout, | |
302 | + $filter | |
303 | + ); | |
304 | + subscriber.onData = function(data) { | |
305 | + dataAggregator.onData(data); | |
306 | + } | |
307 | + subscriber.onReconnected = function() { | |
308 | + dataAggregator.reset(); | |
286 | 309 | onReconnected(); |
287 | 310 | } |
288 | - }; | |
311 | + subscriber.onDestroy = function() { | |
312 | + dataAggregator.destroy(); | |
313 | + } | |
314 | + } else { | |
315 | + subscriber.onReconnected = function() { | |
316 | + onReconnected(); | |
317 | + } | |
318 | + subscriber.onData = function(data) { | |
319 | + if (data.data) { | |
320 | + onData(data.data, types.dataKeyType.timeseries); | |
321 | + } | |
322 | + } | |
323 | + } | |
289 | 324 | |
290 | 325 | telemetryWebsocketService.subscribe(subscriber); |
291 | 326 | subscribers[subscriber.subscriptionCommand.cmdId] = subscriber; |
... | ... | @@ -304,7 +339,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
304 | 339 | subscriptionCommand: subscriptionCommand, |
305 | 340 | type: types.dataKeyType.attribute, |
306 | 341 | onData: function (data) { |
307 | - onData(data, types.dataKeyType.attribute); | |
342 | + if (data.data) { | |
343 | + onData(data.data, types.dataKeyType.attribute); | |
344 | + } | |
308 | 345 | }, |
309 | 346 | onReconnected: function() { |
310 | 347 | onReconnected(); |
... | ... | @@ -332,11 +369,14 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
332 | 369 | } |
333 | 370 | if (datasourceType === types.datasourceType.device) { |
334 | 371 | for (var cmdId in subscribers) { |
335 | - telemetryWebsocketService.unsubscribe(subscribers[cmdId]); | |
372 | + var subscriber = subscribers[cmdId]; | |
373 | + telemetryWebsocketService.unsubscribe(subscriber); | |
374 | + if (subscriber.onDestroy) { | |
375 | + subscriber.onDestroy(); | |
376 | + } | |
336 | 377 | } |
337 | 378 | subscribers = {}; |
338 | 379 | } |
339 | - //$log.debug("unsibscribed!"); | |
340 | 380 | } |
341 | 381 | |
342 | 382 | function boundToInterval(data, timewindowMs) { |
... | ... | @@ -360,7 +400,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
360 | 400 | function generateSeries(dataKey, startTime, endTime) { |
361 | 401 | var data = []; |
362 | 402 | var prevSeries; |
363 | - var datasourceKeyData = datasourceData[dataKey.key]; | |
403 | + var datasourceKeyData = datasourceData[dataKey.key].data; | |
364 | 404 | if (datasourceKeyData.length > 0) { |
365 | 405 | prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; |
366 | 406 | } else { |
... | ... | @@ -378,10 +418,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
378 | 418 | dataKey.lastUpdateTime = data[data.length - 1][0]; |
379 | 419 | } |
380 | 420 | if (realtime) { |
381 | - datasourceData[dataKey.key] = boundToInterval(datasourceKeyData.concat(data), | |
421 | + datasourceData[dataKey.key].data = boundToInterval(datasourceKeyData.concat(data), | |
382 | 422 | datasourceSubscription.subscriptionTimewindow.realtimeWindowMs); |
383 | 423 | } else { |
384 | - datasourceData[dataKey.key] = data; | |
424 | + datasourceData[dataKey.key].data = data; | |
385 | 425 | } |
386 | 426 | for (var i in listeners) { |
387 | 427 | var listener = listeners[i]; |
... | ... | @@ -393,7 +433,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
393 | 433 | |
394 | 434 | function generateLatest(dataKey) { |
395 | 435 | var prevSeries; |
396 | - var datasourceKeyData = datasourceData[dataKey.key]; | |
436 | + var datasourceKeyData = datasourceData[dataKey.key].data; | |
397 | 437 | if (datasourceKeyData.length > 0) { |
398 | 438 | prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; |
399 | 439 | } else { |
... | ... | @@ -404,7 +444,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
404 | 444 | series.push(time); |
405 | 445 | var value = dataKey.func(time, prevSeries[1]); |
406 | 446 | series.push(value); |
407 | - datasourceData[dataKey.key] = [series]; | |
447 | + datasourceData[dataKey.key].data = [series]; | |
408 | 448 | for (var i in listeners) { |
409 | 449 | var listener = listeners[i]; |
410 | 450 | listener.dataUpdated(datasourceData[dataKey.key], |
... | ... | @@ -453,7 +493,9 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
453 | 493 | for (var i = 0; i < dataKeysList.length; i++) { |
454 | 494 | var dataKey = dataKeysList[i]; |
455 | 495 | var datasourceKey = key + '_' + i; |
456 | - datasourceData[datasourceKey] = []; | |
496 | + datasourceData[datasourceKey] = { | |
497 | + data: [] | |
498 | + }; | |
457 | 499 | for (var l in listeners) { |
458 | 500 | var listener = listeners[l]; |
459 | 501 | listener.dataUpdated(datasourceData[datasourceKey], |
... | ... | @@ -477,18 +519,23 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
477 | 519 | } |
478 | 520 | } |
479 | 521 | |
480 | - function onData(sourceData, type) { | |
522 | + function onData(sourceData, type, startTs, endTs) { | |
481 | 523 | for (var keyName in sourceData) { |
482 | 524 | var keyData = sourceData[keyName]; |
483 | 525 | var key = keyName + '_' + type; |
484 | 526 | var dataKeyList = dataKeys[key]; |
485 | 527 | for (var keyIndex = 0; keyIndex < dataKeyList.length; keyIndex++) { |
486 | 528 | var datasourceKey = key + "_" + keyIndex; |
487 | - if (datasourceData[datasourceKey]) { | |
529 | + if (datasourceData[datasourceKey].data) { | |
488 | 530 | var dataKey = dataKeyList[keyIndex]; |
489 | 531 | var data = []; |
490 | 532 | var prevSeries; |
491 | - var datasourceKeyData = datasourceData[datasourceKey]; | |
533 | + var datasourceKeyData; | |
534 | + if (realtime) { | |
535 | + datasourceKeyData = []; | |
536 | + } else { | |
537 | + datasourceKeyData = datasourceData[datasourceKey].data; | |
538 | + } | |
492 | 539 | if (datasourceKeyData.length > 0) { |
493 | 540 | prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; |
494 | 541 | } else { |
... | ... | @@ -519,12 +566,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
519 | 566 | data.push(series); |
520 | 567 | } |
521 | 568 | } |
522 | - if (data.length > 0) { | |
523 | - if (realtime) { | |
524 | - datasourceData[datasourceKey] = boundToInterval(datasourceKeyData.concat(data), datasourceSubscription.subscriptionTimewindow.realtimeWindowMs); | |
525 | - } else { | |
526 | - datasourceData[datasourceKey] = data; | |
527 | - } | |
569 | + if (data.length > 0 || (startTs && endTs)) { | |
570 | + datasourceData[datasourceKey].data = data; | |
571 | + datasourceData[datasourceKey].startTs = startTs; | |
572 | + datasourceData[datasourceKey].endTs = endTs; | |
528 | 573 | for (var i2 in listeners) { |
529 | 574 | var listener = listeners[i2]; |
530 | 575 | listener.dataUpdated(datasourceData[datasourceKey], |
... | ... | @@ -537,3 +582,4 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic |
537 | 582 | } |
538 | 583 | } |
539 | 584 | } |
585 | + | ... | ... |
... | ... | @@ -304,7 +304,9 @@ function DeviceService($http, $q, $filter, telemetryWebsocketService, types) { |
304 | 304 | subscriptionCommand: subscriptionCommand, |
305 | 305 | type: type, |
306 | 306 | onData: function (data) { |
307 | - onSubscriptionData(data, subscriptionId); | |
307 | + if (data.data) { | |
308 | + onSubscriptionData(data.data, subscriptionId); | |
309 | + } | |
308 | 310 | } |
309 | 311 | }; |
310 | 312 | deviceAttributesSubscription = { | ... | ... |
... | ... | @@ -131,8 +131,8 @@ function TelemetryWebsocketService($rootScope, $websocket, $timeout, $window, ty |
131 | 131 | var data = angular.fromJson(message.data); |
132 | 132 | if (data.subscriptionId) { |
133 | 133 | var subscriber = subscribers[data.subscriptionId]; |
134 | - if (subscriber && data.data) { | |
135 | - subscriber.onData(data.data); | |
134 | + if (subscriber && data) { | |
135 | + subscriber.onData(data); | |
136 | 136 | } |
137 | 137 | } |
138 | 138 | } | ... | ... |
... | ... | @@ -33,6 +33,32 @@ export default angular.module('thingsboard.types', []) |
33 | 33 | id: { |
34 | 34 | nullUid: "13814000-1dd2-11b2-8080-808080808080", |
35 | 35 | }, |
36 | + aggregation: { | |
37 | + min: { | |
38 | + value: "MIN", | |
39 | + name: "aggregation.min" | |
40 | + }, | |
41 | + max: { | |
42 | + value: "MAX", | |
43 | + name: "aggregation.max" | |
44 | + }, | |
45 | + avg: { | |
46 | + value: "AVG", | |
47 | + name: "aggregation.avg" | |
48 | + }, | |
49 | + sum: { | |
50 | + value: "SUM", | |
51 | + name: "aggregation.sum" | |
52 | + }, | |
53 | + count: { | |
54 | + value: "COUNT", | |
55 | + name: "aggregation.count" | |
56 | + }, | |
57 | + none: { | |
58 | + value: "NONE", | |
59 | + name: "aggregation.none" | |
60 | + } | |
61 | + }, | |
36 | 62 | datasourceType: { |
37 | 63 | function: "function", |
38 | 64 | device: "device" | ... | ... |
... | ... | @@ -47,7 +47,7 @@ |
47 | 47 | padding: vm.widgetPadding(widget)}"> |
48 | 48 | <div class="tb-widget-title" layout="column" ng-show="vm.showWidgetTitle(widget) || vm.hasTimewindow(widget)"> |
49 | 49 | <span ng-show="vm.showWidgetTitle(widget)" ng-style="vm.widgetTitleStyle(widget)" class="md-subhead">{{widget.config.title}}</span> |
50 | - <tb-timewindow button-color="vm.widgetColor(widget)" ng-if="vm.hasTimewindow(widget)" ng-model="widget.config.timewindow"></tb-timewindow> | |
50 | + <tb-timewindow button-color="vm.widgetColor(widget)" aggregation ng-if="vm.hasTimewindow(widget)" ng-model="widget.config.timewindow"></tb-timewindow> | |
51 | 51 | </div> |
52 | 52 | <div class="tb-widget-actions" layout="row" layout-align="start center"> |
53 | 53 | <md-button id="expand-button" | ... | ... |
... | ... | @@ -14,14 +14,16 @@ |
14 | 14 | * limitations under the License. |
15 | 15 | */ |
16 | 16 | /*@ngInject*/ |
17 | -export default function TimewindowPanelController(mdPanelRef, $scope, timewindow, historyOnly, onTimewindowUpdate) { | |
17 | +export default function TimewindowPanelController(mdPanelRef, $scope, types, timewindow, historyOnly, aggregation, onTimewindowUpdate) { | |
18 | 18 | |
19 | 19 | var vm = this; |
20 | 20 | |
21 | 21 | vm._mdPanelRef = mdPanelRef; |
22 | 22 | vm.timewindow = timewindow; |
23 | 23 | vm.historyOnly = historyOnly; |
24 | + vm.aggregation = aggregation; | |
24 | 25 | vm.onTimewindowUpdate = onTimewindowUpdate; |
26 | + vm.aggregationTypes = types.aggregation; | |
25 | 27 | |
26 | 28 | if (vm.historyOnly) { |
27 | 29 | vm.timewindow.selectedTab = 1; | ... | ... |
... | ... | @@ -17,7 +17,7 @@ |
17 | 17 | --> |
18 | 18 | <form name="theForm" ng-submit="vm.update()"> |
19 | 19 | <fieldset ng-disabled="loading"> |
20 | - <section layout="column"> | |
20 | + <md-content layout="column"> | |
21 | 21 | <md-tabs ng-class="{'tb-headless': vm.historyOnly}" flex md-dynamic-height md-selected="vm.timewindow.selectedTab" md-border-bottom> |
22 | 22 | <md-tab label="{{ 'timewindow.realtime' | translate }}"> |
23 | 23 | <md-content class="md-padding" layout="column"> |
... | ... | @@ -52,6 +52,24 @@ |
52 | 52 | </md-content> |
53 | 53 | </md-tab> |
54 | 54 | </md-tabs> |
55 | + <md-content ng-if="vm.aggregation" class="md-padding" layout="column"> | |
56 | + <md-input-container> | |
57 | + <label translate>aggregation.function</label> | |
58 | + <md-select ng-model="vm.timewindow.aggregation.type" style="min-width: 150px;"> | |
59 | + <md-option ng-repeat="type in vm.aggregationTypes" ng-value="type.value"> | |
60 | + {{type.name | translate}} | |
61 | + </md-option> | |
62 | + </md-select> | |
63 | + </md-input-container> | |
64 | + <md-slider-container> | |
65 | + <span translate>aggregation.limit</span> | |
66 | + <md-slider flex min="10" max="500" ng-model="vm.timewindow.aggregation.limit" aria-label="limit" id="limit-slider"> | |
67 | + </md-slider> | |
68 | + <md-input-container> | |
69 | + <input flex type="number" ng-model="vm.timewindow.aggregation.limit" aria-label="limit" aria-controls="limit-slider"> | |
70 | + </md-input-container> | |
71 | + </md-slider-container> | |
72 | + </md-content> | |
55 | 73 | <section layout="row" layout-alignment="start center"> |
56 | 74 | <span flex></span> |
57 | 75 | <md-button ng-disabled="loading || theForm.$invalid || !theForm.$dirty" type="submit" class="md-raised md-primary"> | ... | ... |
... | ... | @@ -15,6 +15,7 @@ |
15 | 15 | */ |
16 | 16 | import './timewindow.scss'; |
17 | 17 | |
18 | +import $ from 'jquery'; | |
18 | 19 | import thingsboardTimeinterval from './timeinterval.directive'; |
19 | 20 | import thingsboardDatetimePeriod from './datetime-period.directive'; |
20 | 21 | |
... | ... | @@ -34,8 +35,9 @@ export default angular.module('thingsboard.directives.timewindow', [thingsboardT |
34 | 35 | .filter('milliSecondsToTimeString', MillisecondsToTimeString) |
35 | 36 | .name; |
36 | 37 | |
38 | +/* eslint-disable angular/angularelement */ | |
37 | 39 | /*@ngInject*/ |
38 | -function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $translate) { | |
40 | +function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdMedia, $translate, types) { | |
39 | 41 | |
40 | 42 | var linker = function (scope, element, attrs, ngModelCtrl) { |
41 | 43 | |
... | ... | @@ -50,12 +52,18 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra |
50 | 52 | * startTimeMs: 0, |
51 | 53 | * endTimeMs: 0 |
52 | 54 | * } |
55 | + * }, | |
56 | + * aggregation: { | |
57 | + * limit: 200, | |
58 | + * type: types.aggregation.avg.value | |
53 | 59 | * } |
54 | 60 | * } |
55 | 61 | */ |
56 | 62 | |
57 | 63 | scope.historyOnly = angular.isDefined(attrs.historyOnly); |
58 | 64 | |
65 | + scope.aggregation = angular.isDefined(attrs.aggregation); | |
66 | + | |
59 | 67 | var translationPending = false; |
60 | 68 | |
61 | 69 | $translate.onReady(function() { |
... | ... | @@ -84,9 +92,27 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra |
84 | 92 | } |
85 | 93 | |
86 | 94 | scope.openEditMode = function (event) { |
87 | - var position = $mdPanel.newPanelPosition() | |
88 | - .relativeTo(element) | |
89 | - .addPanelPosition($mdPanel.xPosition.ALIGN_START, $mdPanel.yPosition.BELOW); | |
95 | + var position; | |
96 | + var isGtSm = $mdMedia('gt-sm'); | |
97 | + if (isGtSm) { | |
98 | + var panelHeight = 375; | |
99 | + var offset = element[0].getBoundingClientRect(); | |
100 | + var bottomY = offset.bottom - $(window).scrollTop(); //eslint-disable-line | |
101 | + var yPosition; | |
102 | + if (bottomY + panelHeight > $( window ).height()) { //eslint-disable-line | |
103 | + yPosition = $mdPanel.yPosition.ABOVE; | |
104 | + } else { | |
105 | + yPosition = $mdPanel.yPosition.BELOW; | |
106 | + } | |
107 | + position = $mdPanel.newPanelPosition() | |
108 | + .relativeTo(element) | |
109 | + .addPanelPosition($mdPanel.xPosition.ALIGN_START, yPosition); | |
110 | + } else { | |
111 | + position = $mdPanel.newPanelPosition() | |
112 | + .absolute() | |
113 | + .top('0%') | |
114 | + .left('0%'); | |
115 | + } | |
90 | 116 | var config = { |
91 | 117 | attachTo: angular.element($document[0].body), |
92 | 118 | controller: 'TimewindowPanelController', |
... | ... | @@ -94,9 +120,11 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra |
94 | 120 | templateUrl: timewindowPanelTemplate, |
95 | 121 | panelClass: 'tb-timewindow-panel', |
96 | 122 | position: position, |
123 | + fullscreen: !isGtSm, | |
97 | 124 | locals: { |
98 | 125 | 'timewindow': angular.copy(scope.model), |
99 | 126 | 'historyOnly': scope.historyOnly, |
127 | + 'aggregation': scope.aggregation, | |
100 | 128 | 'onTimewindowUpdate': function (timewindow) { |
101 | 129 | scope.model = timewindow; |
102 | 130 | scope.updateView(); |
... | ... | @@ -131,7 +159,10 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra |
131 | 159 | }; |
132 | 160 | } |
133 | 161 | } |
134 | - | |
162 | + value.aggregation = { | |
163 | + limit: model.aggregation.limit, | |
164 | + type: model.aggregation.type | |
165 | + }; | |
135 | 166 | ngModelCtrl.$setViewValue(value); |
136 | 167 | scope.updateDisplayValue(); |
137 | 168 | } |
... | ... | @@ -173,6 +204,10 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra |
173 | 204 | startTimeMs: currentTime - 24 * 60 * 60 * 1000, // 1 day by default |
174 | 205 | endTimeMs: currentTime |
175 | 206 | } |
207 | + }, | |
208 | + aggregation: { | |
209 | + limit: 200, | |
210 | + type: types.aggregation.avg.value | |
176 | 211 | } |
177 | 212 | }; |
178 | 213 | if (ngModelCtrl.$viewValue) { |
... | ... | @@ -192,6 +227,12 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $tra |
192 | 227 | model.history.fixedTimewindow.endTimeMs = value.history.fixedTimewindow.endTimeMs; |
193 | 228 | } |
194 | 229 | } |
230 | + if (angular.isDefined(value.aggregation)) { | |
231 | + model.aggregation.limit = value.aggregation.limit || 200; | |
232 | + if (angular.isDefined(value.aggregation.type) && value.aggregation.type.length > 0) { | |
233 | + model.aggregation.type = value.aggregation.type; | |
234 | + } | |
235 | + } | |
195 | 236 | } |
196 | 237 | scope.updateDisplayValue(); |
197 | 238 | }; |
... | ... | @@ -240,4 +281,5 @@ function MillisecondsToTimeString($translate) { |
240 | 281 | } |
241 | 282 | return timeString; |
242 | 283 | } |
243 | -} | |
\ No newline at end of file | ||
284 | +} | |
285 | +/* eslint-enable angular/angularelement */ | |
\ No newline at end of file | ... | ... |
... | ... | @@ -13,8 +13,15 @@ |
13 | 13 | * See the License for the specific language governing permissions and |
14 | 14 | * limitations under the License. |
15 | 15 | */ |
16 | + | |
17 | +.md-panel { | |
18 | + &.tb-timewindow-panel { | |
19 | + position: absolute; | |
20 | + } | |
21 | +} | |
22 | + | |
16 | 23 | .tb-timewindow-panel { |
17 | - position: absolute; | |
24 | + min-height: 375px; | |
18 | 25 | background: white; |
19 | 26 | border-radius: 4px; |
20 | 27 | box-shadow: 0 7px 8px -4px rgba(0, 0, 0, 0.2), | ... | ... |
... | ... | @@ -91,7 +91,7 @@ |
91 | 91 | <div ng-show="widgetType === types.widgetType.timeseries.value" layout="row" |
92 | 92 | layout-align="center center"> |
93 | 93 | <span translate style="padding-right: 8px;">widget-config.timewindow</span> |
94 | - <tb-timewindow as-button="true" flex ng-model="timewindow"></tb-timewindow> | |
94 | + <tb-timewindow as-button="true" aggregation flex ng-model="timewindow"></tb-timewindow> | |
95 | 95 | </div> |
96 | 96 | <v-accordion id="datasources-accordion" control="datasourcesAccordion" class="vAccordion--default" |
97 | 97 | ng-show="widgetType !== types.widgetType.rpc.value && widgetType !== types.widgetType.static.value"> | ... | ... |
... | ... | @@ -43,9 +43,9 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
43 | 43 | var originalTimewindow = null; |
44 | 44 | var subscriptionTimewindow = { |
45 | 45 | fixedWindow: null, |
46 | - realtimeWindowMs: null | |
46 | + realtimeWindowMs: null, | |
47 | + aggregation: null | |
47 | 48 | }; |
48 | - var timer = null; | |
49 | 49 | var dataUpdateTimer = null; |
50 | 50 | var dataUpdateCaf = null; |
51 | 51 | |
... | ... | @@ -154,10 +154,10 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
154 | 154 | } |
155 | 155 | } |
156 | 156 | |
157 | - function updateTimewindow() { | |
157 | + function updateTimewindow(startTs, endTs) { | |
158 | 158 | if (subscriptionTimewindow.realtimeWindowMs) { |
159 | - widgetContext.timeWindow.maxTime = (new Date).getTime(); | |
160 | - widgetContext.timeWindow.minTime = widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs; | |
159 | + widgetContext.timeWindow.maxTime = endTs || (new Date).getTime(); | |
160 | + widgetContext.timeWindow.minTime = startTs || (widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs); | |
161 | 161 | } else if (subscriptionTimewindow.fixedWindow) { |
162 | 162 | widgetContext.timeWindow.maxTime = subscriptionTimewindow.fixedWindow.endTimeMs; |
163 | 163 | widgetContext.timeWindow.minTime = subscriptionTimewindow.fixedWindow.startTimeMs; |
... | ... | @@ -170,13 +170,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
170 | 170 | dataUpdateTimer = null; |
171 | 171 | } |
172 | 172 | if (widgetContext.inited) { |
173 | - if (widget.type === types.widgetType.timeseries.value) { | |
174 | - if (!widgetContext.tickUpdate && timer) { | |
175 | - $timeout.cancel(timer); | |
176 | - timer = $timeout(onTick, 1500, false); | |
177 | - } | |
178 | - updateTimewindow(); | |
179 | - } | |
180 | 173 | if (dataUpdateCaf) { |
181 | 174 | dataUpdateCaf(); |
182 | 175 | dataUpdateCaf = null; |
... | ... | @@ -188,7 +181,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
188 | 181 | handleWidgetException(e); |
189 | 182 | } |
190 | 183 | }); |
191 | - widgetContext.tickUpdate = false; | |
192 | 184 | } else { |
193 | 185 | widgetContext.dataUpdatePending = true; |
194 | 186 | } |
... | ... | @@ -512,17 +504,20 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
512 | 504 | var update = true; |
513 | 505 | if (widget.type === types.widgetType.latest.value) { |
514 | 506 | var prevData = widgetContext.data[datasourceIndex + dataKeyIndex].data; |
515 | - if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.length > 0) { | |
507 | + if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.data.length > 0) { | |
516 | 508 | var prevValue = prevData[0][1]; |
517 | - if (prevValue === sourceData[0][1]) { | |
509 | + if (prevValue === sourceData.data[0][1]) { | |
518 | 510 | update = false; |
519 | 511 | } |
520 | 512 | } |
521 | 513 | } |
522 | 514 | if (update) { |
523 | - widgetContext.data[datasourceIndex + dataKeyIndex].data = sourceData; | |
515 | + if (subscriptionTimewindow.realtimeWindowMs) { | |
516 | + updateTimewindow(sourceData.startTs, sourceData.endTs); | |
517 | + } | |
518 | + widgetContext.data[datasourceIndex + dataKeyIndex].data = sourceData.data; | |
524 | 519 | if (widgetContext.data.length > 1 && !dataUpdateTimer) { |
525 | - dataUpdateTimer = $timeout(onDataUpdated, 100, false); | |
520 | + dataUpdateTimer = $timeout(onDataUpdated, 300, false); | |
526 | 521 | } else { |
527 | 522 | onDataUpdated(); |
528 | 523 | } |
... | ... | @@ -557,10 +552,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
557 | 552 | |
558 | 553 | function unsubscribe() { |
559 | 554 | if (widget.type !== types.widgetType.rpc.value) { |
560 | - if (timer) { | |
561 | - $timeout.cancel(timer); | |
562 | - timer = null; | |
563 | - } | |
564 | 555 | if (dataUpdateTimer) { |
565 | 556 | $timeout.cancel(dataUpdateTimer); |
566 | 557 | dataUpdateTimer = null; |
... | ... | @@ -573,19 +564,25 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
573 | 564 | } |
574 | 565 | } |
575 | 566 | |
576 | - function onTick() { | |
577 | - widgetContext.tickUpdate = true; | |
578 | - onDataUpdated(); | |
579 | - timer = $timeout(onTick, 1000, false); | |
580 | - } | |
581 | - | |
582 | 567 | function subscribe() { |
583 | 568 | if (widget.type !== types.widgetType.rpc.value) { |
584 | 569 | var index = 0; |
585 | 570 | subscriptionTimewindow.fixedWindow = null; |
586 | 571 | subscriptionTimewindow.realtimeWindowMs = null; |
572 | + subscriptionTimewindow.aggregation = { | |
573 | + limit: 200, | |
574 | + type: types.aggregation.avg.value | |
575 | + }; | |
587 | 576 | if (widget.type === types.widgetType.timeseries.value && |
588 | 577 | angular.isDefined(widget.config.timewindow)) { |
578 | + | |
579 | + if (angular.isDefined(widget.config.timewindow.aggregation)) { | |
580 | + subscriptionTimewindow.aggregation = { | |
581 | + limit: widget.config.timewindow.aggregation.limit || 200, | |
582 | + type: widget.config.timewindow.aggregation.type || types.aggregation.avg.value | |
583 | + }; | |
584 | + } | |
585 | + | |
589 | 586 | if (angular.isDefined(widget.config.timewindow.realtime)) { |
590 | 587 | subscriptionTimewindow.realtimeWindowMs = widget.config.timewindow.realtime.timewindowMs; |
591 | 588 | } else if (angular.isDefined(widget.config.timewindow.history)) { |
... | ... | @@ -635,10 +632,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q |
635 | 632 | datasourceListeners.push(listener); |
636 | 633 | datasourceService.subscribeToDatasource(listener); |
637 | 634 | } |
638 | - | |
639 | - if (subscriptionTimewindow.realtimeWindowMs) { | |
640 | - timer = $timeout(onTick, 0, false); | |
641 | - } | |
642 | 635 | } |
643 | 636 | } |
644 | 637 | ... | ... |
... | ... | @@ -63,6 +63,17 @@ export default angular.module('thingsboard.locale', []) |
63 | 63 | "import": "Import", |
64 | 64 | "export": "Export" |
65 | 65 | }, |
66 | + "aggregation": { | |
67 | + "aggregation": "Aggregation", | |
68 | + "function": "Data aggregation function", | |
69 | + "limit": "Max values", | |
70 | + "min": "Min", | |
71 | + "max": "Max", | |
72 | + "avg": "Average", | |
73 | + "sum": "Sum", | |
74 | + "count": "Count", | |
75 | + "none": "None" | |
76 | + }, | |
66 | 77 | "admin": { |
67 | 78 | "general": "General", |
68 | 79 | "general-settings": "General Settings", | ... | ... |