committed by
GitHub
97 changed files with 4292 additions and 580 deletions
@ -0,0 +1,52 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.calculatedField; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.audit.ActionType; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.MsgType; |
|||
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
|
|||
@Data |
|||
public class CalculatedFieldRelationActionMsg implements ToCalculatedFieldSystemMsg { |
|||
|
|||
private final TenantId tenantId; |
|||
private final EntityId relatedEntityId; |
|||
private final ActionType action; |
|||
private final CalculatedFieldCtx calculatedField; |
|||
private final TbCallback callback; |
|||
|
|||
public CalculatedFieldRelationActionMsg(TenantId tenantId, |
|||
EntityId relatedEntityId, ActionType action, |
|||
CalculatedFieldCtx calculatedField, |
|||
TbCallback callback) { |
|||
this.tenantId = tenantId; |
|||
this.relatedEntityId = relatedEntityId; |
|||
this.action = action; |
|||
this.calculatedField = calculatedField; |
|||
this.callback = callback; |
|||
} |
|||
|
|||
@Override |
|||
public MsgType getMsgType() { |
|||
return MsgType.CF_RELATION_ACTION_MSG; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,188 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.actors.TbActorRef; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.Output; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggInput; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.service.cf.CalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
import org.thingsboard.server.service.cf.ctx.state.aggregation.function.AggEntry; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import java.util.Map.Entry; |
|||
|
|||
import static java.util.concurrent.TimeUnit.SECONDS; |
|||
|
|||
@Slf4j |
|||
@Getter |
|||
public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculatedFieldState { |
|||
|
|||
@Setter |
|||
private long lastArgsRefreshTs = -1; |
|||
@Setter |
|||
private long lastMetricsEvalTs = -1; |
|||
private long deduplicationIntervalMs = -1; |
|||
private Map<String, AggMetric> metrics; |
|||
|
|||
public RelatedEntitiesAggregationCalculatedFieldState(EntityId entityId) { |
|||
super(entityId); |
|||
} |
|||
|
|||
@Override |
|||
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { |
|||
super.setCtx(ctx, actorCtx); |
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
|||
metrics = configuration.getMetrics(); |
|||
deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec()); |
|||
} |
|||
|
|||
public void scheduleReevaluation() { |
|||
ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx); |
|||
} |
|||
|
|||
@Override |
|||
public void reset() { // must reset everything dependent on arguments
|
|||
super.reset(); |
|||
lastArgsRefreshTs = -1; |
|||
lastMetricsEvalTs = -1; |
|||
metrics = null; |
|||
} |
|||
|
|||
@Override |
|||
public CalculatedFieldType getType() { |
|||
return CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; |
|||
} |
|||
|
|||
@Override |
|||
public Map<String, ArgumentEntry> update(Map<String, ArgumentEntry> argumentValues, CalculatedFieldCtx ctx) { |
|||
lastArgsRefreshTs = System.currentTimeMillis(); |
|||
return super.update(argumentValues, ctx); |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception { |
|||
boolean cfUpdated = updatedArgs != null && updatedArgs.isEmpty(); |
|||
if (shouldRecalculate() || cfUpdated) { |
|||
Output output = ctx.getOutput(); |
|||
ObjectNode aggResult = aggregateMetrics(output); |
|||
lastMetricsEvalTs = System.currentTimeMillis(); |
|||
ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx); |
|||
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() |
|||
.type(output.getType()) |
|||
.scope(output.getScope()) |
|||
.result(toSimpleResult(ctx.isUseLatestTs(), aggResult)) |
|||
.build()); |
|||
} else { |
|||
return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY); |
|||
} |
|||
} |
|||
|
|||
public Map<String, ArgumentEntry> updateEntityData(Map<String, ArgumentEntry> fetchedArgs) { |
|||
lastMetricsEvalTs = -1; |
|||
return update(fetchedArgs, ctx); |
|||
} |
|||
|
|||
public void cleanupEntityData(EntityId relatedEntityId) { |
|||
arguments.values().forEach(argEntry -> { |
|||
RelatedEntitiesArgumentEntry aggEntry = (RelatedEntitiesArgumentEntry) argEntry; |
|||
aggEntry.getEntityInputs().remove(relatedEntityId); |
|||
}); |
|||
lastMetricsEvalTs = -1; |
|||
lastArgsRefreshTs = System.currentTimeMillis(); |
|||
} |
|||
|
|||
private boolean shouldRecalculate() { |
|||
boolean intervalPassed = lastMetricsEvalTs <= System.currentTimeMillis() - deduplicationIntervalMs; |
|||
boolean argsUpdatedDuringInterval = lastArgsRefreshTs > lastMetricsEvalTs; |
|||
return intervalPassed && argsUpdatedDuringInterval; |
|||
} |
|||
|
|||
private Map<EntityId, Map<String, ArgumentEntry>> prepareInputs() { |
|||
Map<EntityId, Map<String, ArgumentEntry>> inputs = new HashMap<>(); |
|||
for (Map.Entry<String, ArgumentEntry> argEntry : arguments.entrySet()) { |
|||
String key = argEntry.getKey(); |
|||
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = (RelatedEntitiesArgumentEntry) argEntry.getValue(); |
|||
relatedEntitiesArgumentEntry.getEntityInputs().forEach((entityId, argumentEntry) -> { |
|||
inputs.computeIfAbsent(entityId, k -> new HashMap<>()).put(key, argumentEntry); |
|||
}); |
|||
} |
|||
return inputs; |
|||
} |
|||
|
|||
private ObjectNode aggregateMetrics(Output output) throws Exception { |
|||
ObjectNode aggResult = JacksonUtil.newObjectNode(); |
|||
Map<EntityId, Map<String, ArgumentEntry>> inputs = prepareInputs(); |
|||
for (Entry<String, AggMetric> entry : metrics.entrySet()) { |
|||
String metricKey = entry.getKey(); |
|||
AggMetric metric = entry.getValue(); |
|||
|
|||
AggEntry aggMetricEntry = AggEntry.createAggFunction(metric.getFunction()); |
|||
aggregateMetric(metric, aggMetricEntry, inputs); |
|||
aggMetricEntry.result(output.getDecimalsByDefault()).ifPresent(result -> { |
|||
aggResult.set(metricKey, JacksonUtil.valueToTree(result)); |
|||
}); |
|||
} |
|||
return aggResult; |
|||
} |
|||
|
|||
private void aggregateMetric(AggMetric metric, AggEntry aggEntry, Map<EntityId, Map<String, ArgumentEntry>> inputs) throws Exception { |
|||
for (Map<String, ArgumentEntry> entityInputs : inputs.values()) { |
|||
if (applyAggregation(metric.getFilter(), entityInputs)) { |
|||
Object arg = resolveAggregationInput(metric.getInput(), entityInputs); |
|||
if (arg != null) { |
|||
aggEntry.update(arg); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private boolean applyAggregation(String filter, Map<String, ArgumentEntry> entityInputs) throws Exception { |
|||
if (filter == null || filter.isEmpty()) { |
|||
return true; |
|||
} else { |
|||
Object filterResult = ctx.evaluateTbelExpression(filter, entityInputs, getLatestTimestamp()).get(); |
|||
return filterResult instanceof Boolean booleanResult && booleanResult; |
|||
} |
|||
} |
|||
|
|||
private Object resolveAggregationInput(AggInput aggInput, Map<String, ArgumentEntry> entityInputs) throws Exception { |
|||
if (aggInput instanceof AggFunctionInput functionInput) { |
|||
return ctx.evaluateTbelExpression(functionInput.getFunction(), entityInputs, getLatestTimestamp()).get(); |
|||
} else { |
|||
String inputKey = ((AggKeyInput) aggInput).getKey(); |
|||
return entityInputs.get(inputKey).getValue(); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import org.thingsboard.script.api.tbel.TbelCfArg; |
|||
import org.thingsboard.script.api.tbel.TbelCfRelatedEntitiesArgumentValue; |
|||
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; |
|||
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; |
|||
|
|||
import java.util.Map; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Data |
|||
@AllArgsConstructor |
|||
public class RelatedEntitiesArgumentEntry implements ArgumentEntry { |
|||
|
|||
private final Map<EntityId, ArgumentEntry> entityInputs; |
|||
|
|||
private boolean forceResetPrevious; |
|||
|
|||
@Override |
|||
public ArgumentEntryType getType() { |
|||
return ArgumentEntryType.RELATED_ENTITIES; |
|||
} |
|||
|
|||
@Override |
|||
public Object getValue() { |
|||
return entityInputs; |
|||
} |
|||
|
|||
@Override |
|||
public boolean updateEntry(ArgumentEntry entry) { |
|||
if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { |
|||
entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs); |
|||
return true; |
|||
} else if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { |
|||
if (entry.isForceResetPrevious()) { |
|||
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); |
|||
return true; |
|||
} |
|||
ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId()); |
|||
if (argumentEntry != null) { |
|||
argumentEntry.updateEntry(singleValueArgumentEntry); |
|||
} else { |
|||
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); |
|||
} |
|||
return true; |
|||
} else { |
|||
throw new IllegalArgumentException("Unsupported argument entry type for aggregation argument entry: " + entry.getType()); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public boolean isEmpty() { |
|||
return entityInputs.isEmpty(); |
|||
} |
|||
|
|||
@Override |
|||
public TbelCfArg toTbelCfArg() { |
|||
var inputs = entityInputs.entrySet().stream() |
|||
.collect(Collectors.toMap( |
|||
e -> e.getKey().getId(), |
|||
e -> (TbelCfSingleValueArg) e.getValue().toTbelCfArg() |
|||
)); |
|||
return new TbelCfRelatedEntitiesArgumentValue(inputs); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonIgnore; |
|||
import com.fasterxml.jackson.annotation.JsonSubTypes; |
|||
import com.fasterxml.jackson.annotation.JsonTypeInfo; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
@JsonTypeInfo( |
|||
use = JsonTypeInfo.Id.NAME, |
|||
include = JsonTypeInfo.As.PROPERTY, |
|||
property = "type" |
|||
) |
|||
@JsonSubTypes({ |
|||
@JsonSubTypes.Type(value = AvgAggEntry.class, name = "AVG"), |
|||
@JsonSubTypes.Type(value = CountAggEntry.class, name = "COUNT"), |
|||
@JsonSubTypes.Type(value = CountUniqueAggEntry.class, name = "COUNT_UNIQUE"), |
|||
@JsonSubTypes.Type(value = MaxAggEntry.class, name = "MAX"), |
|||
@JsonSubTypes.Type(value = MinAggEntry.class, name = "MIN"), |
|||
@JsonSubTypes.Type(value = SumAggEntry.class, name = "SUM") |
|||
}) |
|||
public interface AggEntry { |
|||
|
|||
@JsonIgnore |
|||
AggFunction getType(); |
|||
|
|||
void update(Object value); |
|||
|
|||
Optional<Object> result(Integer precision); |
|||
|
|||
static AggEntry createAggFunction(AggFunction function) { |
|||
return switch (function) { |
|||
case MIN -> new MinAggEntry(); |
|||
case MAX -> new MaxAggEntry(); |
|||
case SUM -> new SumAggEntry(); |
|||
case AVG -> new AvgAggEntry(); |
|||
case COUNT -> new CountAggEntry(); |
|||
case COUNT_UNIQUE -> new CountUniqueAggEntry(); |
|||
}; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
|
|||
public class AvgAggEntry extends BaseAggEntry { |
|||
|
|||
private BigDecimal sum = BigDecimal.ZERO; |
|||
private long count = 0L; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value != 0.0) { |
|||
sum = sum.add(BigDecimal.valueOf(value)); |
|||
} |
|||
this.count++; |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
double result = sum.divide(BigDecimal.valueOf(count), RoundingMode.HALF_UP).doubleValue(); |
|||
return TbUtils.roundResult(result, precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.AVG; |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
public abstract class BaseAggEntry implements AggEntry { |
|||
|
|||
private boolean hasResult = false; |
|||
|
|||
@Override |
|||
public void update(Object value) { |
|||
doUpdate(extractDoubleValue(value)); |
|||
hasResult = true; |
|||
} |
|||
|
|||
@Override |
|||
public Optional<Object> result(Integer precision) { |
|||
if (hasResult) { |
|||
hasResult = false; |
|||
return Optional.of(prepareResult(precision)); |
|||
} else { |
|||
return Optional.empty(); |
|||
} |
|||
} |
|||
|
|||
protected abstract void doUpdate(double value); |
|||
|
|||
protected abstract Object prepareResult(Integer precision); |
|||
|
|||
protected double extractDoubleValue(Object value) { |
|||
try { |
|||
if (value instanceof Number number) { |
|||
return number.doubleValue(); |
|||
} |
|||
return Double.parseDouble(value.toString()); |
|||
} catch (Exception e) { |
|||
throw new NumberFormatException("Cannot parse value " + value.toString()); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
public class CountAggEntry implements AggEntry { |
|||
|
|||
private long count = 0L; |
|||
|
|||
@Override |
|||
public void update(Object value) { |
|||
count++; |
|||
} |
|||
|
|||
@Override |
|||
public Optional<Object> result(Integer precision) { |
|||
return Optional.of(count); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.COUNT; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
public class CountUniqueAggEntry implements AggEntry { |
|||
|
|||
private Set<String> items; |
|||
|
|||
@Override |
|||
public void update(Object value) { |
|||
if (value != null) { |
|||
items.add(String.valueOf(value)); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public Optional<Object> result(Integer precision) { |
|||
return Optional.of(items.size()); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.COUNT_UNIQUE; |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
public class MaxAggEntry extends BaseAggEntry { |
|||
|
|||
private double max = Double.MIN_VALUE; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value > max) { |
|||
max = value; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
return TbUtils.roundResult(max, precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.MAX; |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
public class MinAggEntry extends BaseAggEntry { |
|||
|
|||
private double min = Double.MAX_VALUE; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value < min) { |
|||
min = value; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
return TbUtils.roundResult(min, precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.MIN; |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.math.BigDecimal; |
|||
|
|||
public class SumAggEntry extends BaseAggEntry { |
|||
|
|||
private BigDecimal sum = BigDecimal.ZERO; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value != 0.0) { |
|||
sum = sum.add(BigDecimal.valueOf(value)); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
return TbUtils.roundResult(sum.doubleValue(), precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.SUM; |
|||
} |
|||
} |
|||
@ -0,0 +1,786 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ArrayNode; |
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import org.junit.After; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.springframework.test.annotation.DirtiesContext; |
|||
import org.thingsboard.server.common.data.AttributeScope; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.DeviceProfile; |
|||
import org.thingsboard.server.common.data.Tenant; |
|||
import org.thingsboard.server.common.data.User; |
|||
import org.thingsboard.server.common.data.asset.Asset; |
|||
import org.thingsboard.server.common.data.asset.AssetProfile; |
|||
import org.thingsboard.server.common.data.cf.CalculatedField; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.Argument; |
|||
import org.thingsboard.server.common.data.cf.configuration.ArgumentType; |
|||
import org.thingsboard.server.common.data.cf.configuration.Output; |
|||
import org.thingsboard.server.common.data.cf.configuration.OutputType; |
|||
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.debug.DebugSettings; |
|||
import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; |
|||
import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; |
|||
import org.thingsboard.server.common.data.device.data.DeviceData; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.relation.EntityRelation; |
|||
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
|||
import org.thingsboard.server.common.data.relation.RelationPathLevel; |
|||
import org.thingsboard.server.common.data.relation.RelationTypeGroup; |
|||
import org.thingsboard.server.common.data.security.Authority; |
|||
import org.thingsboard.server.controller.AbstractControllerTest; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.awaitility.Awaitility.await; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL; |
|||
|
|||
@DaoSqlTest |
|||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) |
|||
public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractControllerTest { |
|||
|
|||
private Tenant savedTenant; |
|||
|
|||
private DeviceProfile deviceProfile; |
|||
private Device device1; |
|||
private String accessToken1 = "1234567890111"; |
|||
private Device device2; |
|||
private String accessToken2 = "1234567890222"; |
|||
|
|||
private AssetProfile assetProfile; |
|||
private Asset asset; |
|||
|
|||
private final long deduplicationInterval = 5; |
|||
|
|||
@Before |
|||
public void beforeEach() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
updateDefaultTenantProfileConfig(tenantProfileConfig -> { |
|||
tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); |
|||
}); |
|||
|
|||
Tenant tenant = new Tenant(); |
|||
tenant.setTitle("My tenant"); |
|||
savedTenant = saveTenant(tenant); |
|||
assertThat(savedTenant).isNotNull(); |
|||
|
|||
User tenantAdmin = new User(); |
|||
tenantAdmin.setAuthority(Authority.TENANT_ADMIN); |
|||
tenantAdmin.setTenantId(savedTenant.getId()); |
|||
tenantAdmin.setEmail("tenant@thingsboard.org"); |
|||
tenantAdmin.setFirstName("John"); |
|||
tenantAdmin.setLastName("Doe"); |
|||
|
|||
createUserAndLogin(tenantAdmin, "testPassword"); |
|||
|
|||
deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Device Profile"), DeviceProfile.class); |
|||
device1 = createDevice("Device 1", deviceProfile.getId(), accessToken1); |
|||
device2 = createDevice("Device 2", deviceProfile.getId(), accessToken2); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":true}"); |
|||
postTelemetry(device2.getId(), "{\"occupied\":false}"); |
|||
|
|||
assetProfile = doPost("/api/assetProfile", createAssetProfile("Asset Profile"), AssetProfile.class); |
|||
asset = createAsset("Asset", assetProfile.getId()); |
|||
|
|||
createEntityRelation(asset.getId(), device1.getId(), "Contains"); |
|||
createEntityRelation(asset.getId(), device2.getId(), "Contains"); |
|||
} |
|||
|
|||
@After |
|||
public void afterTest() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
deleteTenant(savedTenant.getId()); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfOnProfile_checkInitialAggregation() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(assetProfile.getId()); |
|||
|
|||
await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
|
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testAddEntityToProfile_checkAggregation() throws Exception { |
|||
createOccupancyCF(assetProfile.getId()); |
|||
|
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
postTelemetry(device4.getId(), "{\"occupied\":true}"); |
|||
|
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
|
|||
await().alias("add entity to profile with no related entities and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ObjectNode occupancy = getLatestTelemetry(asset2.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); |
|||
assertThat(occupancy).isNotNull(); |
|||
assertThat(occupancy.get("freeSpaces").get(0).get("value").isNull()).isTrue(); |
|||
assertThat(occupancy.get("occupiedSpaces").get(0).get("value").isNull()).isTrue(); |
|||
assertThat(occupancy.get("totalSpaces").get(0).get("value").isNull()).isTrue(); |
|||
}); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
await().alias("create relations and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeEntityProfile_checkAggregation() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(assetProfile.getId()); |
|||
|
|||
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
|
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
AssetProfile newAssetProfile = createAssetProfile("New Asset Profile"); |
|||
asset2.setAssetProfileId(newAssetProfile.getId()); |
|||
doPost("/api/asset", asset2, Asset.class); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
await().alias("change profile and no aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfOnAssetAndNoTelemetryOnDevices_checkDefaultValueUsed() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform aggregation with default values").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfAndUpdateTelemetry_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteCf_checkNoAggregation() throws Exception { |
|||
CalculatedField cf = createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
doDelete("/api/calculatedField/" + cf.getId().getId().toString()) |
|||
.andExpect(status().isOk()); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("delete cf and update telemetry and no aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateTelemetry_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("update telemetry -> no changes").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(this::checkInitialCalculationValues); |
|||
|
|||
postTelemetry(device2.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("create CF and perform initial calculation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteTelemetry_checkAggregationWithPreviousValuesOrDefault() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
long currentTime = System.currentTimeMillis(); |
|||
long firstTs = currentTime - 10; |
|||
long secondTs = currentTime - 10; |
|||
long thirdTs = currentTime - 5; |
|||
postTelemetry(device3.getId(), "{\"ts\": " + firstTs + ", \"values\": {\"occupied\":true}}"); |
|||
postTelemetry(device4.getId(), "{\"ts\": " + secondTs + ", \"values\": {\"occupied\":true}}"); |
|||
postTelemetry(device3.getId(), "{\"ts\": " + thirdTs + ", \"values\": {\"occupied\":true}}"); |
|||
|
|||
createOccupancyCF(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device3.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + thirdTs + "&endTs=" + thirdTs + 1, String.class); |
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device4.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + secondTs + "&endTs=" + secondTs + 1, String.class); |
|||
|
|||
await().alias("delete latest telemetry and perform aggregation with previous or default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteAttr_checkAggregationWithDefault() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
postAttributes(device3.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); |
|||
postAttributes(device4.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); |
|||
|
|||
createOccupancyCFWithAttr(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device3.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); |
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device4.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); |
|||
|
|||
await().alias("delete attribute and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateRelation_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
Device device3 = createDevice("Device 3", deviceProfile.getId(), "1234567890333"); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
createEntityRelation(asset.getId(), device3.getId(), "Contains"); |
|||
|
|||
await().alias("create relation and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "3" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteRelation_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
deleteEntityRelation(new EntityRelation(asset.getId(), device1.getId(), "Contains", RelationTypeGroup.COMMON)); |
|||
|
|||
await().alias("create relation and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateRelationPath_checkAggregation() throws Exception { |
|||
CalculatedField cf = createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
createEntityRelation(asset.getId(), device3.getId(), "Has"); |
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
configuration.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, "Has")); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update relation path and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateArguments_checkAggregation() throws Exception { |
|||
CalculatedField cf = createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupiedStatus\":false}"); |
|||
postTelemetry(device2.getId(), "{\"occupiedStatus\":false}"); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("oc", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("false"); |
|||
configuration.setArguments(Map.of("oc", argument)); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update arguments and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateMetrics_checkAggregation() throws Exception { |
|||
postTelemetry(device1.getId(), "{\"temperature\":24.2}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":19.6}"); |
|||
CalculatedField cf = createAvgTemperatureCF(asset.getId()); |
|||
|
|||
await().alias("create avg temp cf and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
AggMetric aggMetric = new AggMetric(); |
|||
aggMetric.setInput(new AggKeyInput("temp")); |
|||
aggMetric.setFilter("return temp < 100;"); |
|||
aggMetric.setFunction(AggFunction.MAX); |
|||
configuration.setMetrics(Map.of("maxTemperature", aggMetric)); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update metrics and perform aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("maxTemperature", "24")); |
|||
}); |
|||
|
|||
postTelemetry(device1.getId(), "{\"temperature\":101.3}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":25.8}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("maxTemperature", "26")); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateOutput_checkAggregation() throws Exception { |
|||
postTelemetry(device1.getId(), "{\"temperature\":24.2}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":19.6}"); |
|||
CalculatedField cf = createAvgTemperatureCF(asset.getId()); |
|||
|
|||
await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
Output output = new Output(); |
|||
output.setType(OutputType.ATTRIBUTES); |
|||
output.setScope(AttributeScope.SERVER_SCOPE); |
|||
configuration.setOutput(output); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update output and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ArrayNode avgTemperature = getServerAttributes(asset.getId(), "avgTemperature"); |
|||
assertThat(avgTemperature).isNotNull(); |
|||
assertThat(avgTemperature.get(0)).isNotNull(); |
|||
assertThat(avgTemperature.get(0).get("value").asText()).isEqualTo("24.2"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateDeduplicationInterval_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { |
|||
postTelemetry(device1.getId(), "{\"temperature\":24.2}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":19.6}"); |
|||
CalculatedField cf = createAvgTemperatureCF(asset.getId()); |
|||
|
|||
await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
configuration.setDeduplicationIntervalInSec(2 * deduplicationInterval); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update deduplication interval and perform aggregation").atMost(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
postTelemetry(device2.getId(), "{\"temperature\":32.1}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation").atMost(2 * deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "28")); |
|||
}); |
|||
} |
|||
|
|||
private void checkInitialCalculation() { |
|||
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(this::checkInitialCalculationValues); |
|||
} |
|||
|
|||
private void checkInitialCalculationValues() throws Exception { |
|||
ObjectNode occupancy = getLatestTelemetry(asset.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); |
|||
assertThat(occupancy).isNotNull(); |
|||
assertThat(occupancy.get("freeSpaces").get(0).get("value").asText()).isEqualTo("1"); |
|||
assertThat(occupancy.get("occupiedSpaces").get(0).get("value").asText()).isEqualTo("1"); |
|||
assertThat(occupancy.get("totalSpaces").get(0).get("value").asText()).isEqualTo("2"); |
|||
} |
|||
|
|||
private CalculatedField createAvgTemperatureCF(EntityId entityId) { |
|||
Map<String, Argument> arguments = new HashMap<>(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("20"); |
|||
arguments.put("temp", argument); |
|||
|
|||
Map<String, AggMetric> aggMetrics = new HashMap<>(); |
|||
|
|||
AggMetric avgMetric = new AggMetric(); |
|||
avgMetric.setFunction(AggFunction.AVG); |
|||
avgMetric.setFilter("return temp >= 20;"); |
|||
avgMetric.setInput(new AggKeyInput("temp")); |
|||
aggMetrics.put("avgTemperature", avgMetric); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
return createAggCf("Average temperature", entityId, |
|||
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), |
|||
arguments, |
|||
aggMetrics, |
|||
output); |
|||
} |
|||
|
|||
private CalculatedField createOccupancyCF(EntityId entityId) { |
|||
Map<String, Argument> arguments = new HashMap<>(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("false"); |
|||
arguments.put("oc", argument); |
|||
|
|||
Map<String, AggMetric> aggMetrics = new HashMap<>(); |
|||
|
|||
AggMetric freeSpaces = new AggMetric(); |
|||
freeSpaces.setFunction(AggFunction.COUNT); |
|||
freeSpaces.setFilter("return oc == false;"); |
|||
freeSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("freeSpaces", freeSpaces); |
|||
|
|||
AggMetric occupiedSpaces = new AggMetric(); |
|||
occupiedSpaces.setFunction(AggFunction.COUNT); |
|||
occupiedSpaces.setFilter("return oc == true;"); |
|||
occupiedSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("occupiedSpaces", occupiedSpaces); |
|||
|
|||
AggMetric totalSpaces = new AggMetric(); |
|||
totalSpaces.setFunction(AggFunction.COUNT); |
|||
totalSpaces.setInput(new AggFunctionInput("return 1;")); |
|||
aggMetrics.put("totalSpaces", totalSpaces); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
return createAggCf("Occupied spaces", entityId, |
|||
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), |
|||
arguments, |
|||
aggMetrics, |
|||
output); |
|||
} |
|||
|
|||
private CalculatedField createOccupancyCFWithAttr(EntityId entityId) { |
|||
Map<String, Argument> arguments = new HashMap<>(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
argument.setDefaultValue("false"); |
|||
arguments.put("oc", argument); |
|||
|
|||
Map<String, AggMetric> aggMetrics = new HashMap<>(); |
|||
|
|||
AggMetric freeSpaces = new AggMetric(); |
|||
freeSpaces.setFunction(AggFunction.COUNT); |
|||
freeSpaces.setFilter("return oc == false;"); |
|||
freeSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("freeSpaces", freeSpaces); |
|||
|
|||
AggMetric occupiedSpaces = new AggMetric(); |
|||
occupiedSpaces.setFunction(AggFunction.COUNT); |
|||
occupiedSpaces.setFilter("return oc == true;"); |
|||
occupiedSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("occupiedSpaces", occupiedSpaces); |
|||
|
|||
AggMetric totalSpaces = new AggMetric(); |
|||
totalSpaces.setFunction(AggFunction.COUNT); |
|||
totalSpaces.setInput(new AggFunctionInput("return 1;")); |
|||
aggMetrics.put("totalSpaces", totalSpaces); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
return createAggCf("Occupied spaces", entityId, |
|||
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), |
|||
arguments, |
|||
aggMetrics, |
|||
output); |
|||
} |
|||
|
|||
private CalculatedField createAggCf(String name, |
|||
EntityId entityId, |
|||
RelationPathLevel relation, |
|||
Map<String, Argument> inputs, |
|||
Map<String, AggMetric> metrics, |
|||
Output output) { |
|||
CalculatedField calculatedField = new CalculatedField(); |
|||
calculatedField.setName(name); |
|||
calculatedField.setEntityId(entityId); |
|||
calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); |
|||
|
|||
RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); |
|||
configuration.setRelation(relation); |
|||
configuration.setArguments(inputs); |
|||
configuration.setDeduplicationIntervalInSec(deduplicationInterval); |
|||
configuration.setMetrics(metrics); |
|||
configuration.setOutput(output); |
|||
|
|||
calculatedField.setConfiguration(configuration); |
|||
calculatedField.setDebugSettings(DebugSettings.all()); |
|||
return saveCalculatedField(calculatedField); |
|||
} |
|||
|
|||
private Device createDevice(String name, DeviceProfileId deviceProfileId, String accessToken) { |
|||
Device device = new Device(); |
|||
device.setName(name); |
|||
device.setDeviceProfileId(deviceProfileId); |
|||
DeviceData deviceData = new DeviceData(); |
|||
deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); |
|||
deviceData.setConfiguration(new DefaultDeviceConfiguration()); |
|||
device.setDeviceData(deviceData); |
|||
return doPost("/api/device?accessToken=" + accessToken, device, Device.class); |
|||
} |
|||
|
|||
private Asset createAsset(String name, AssetProfileId assetProfileId) { |
|||
Asset asset = new Asset(); |
|||
asset.setName(name); |
|||
asset.setAssetProfileId(assetProfileId); |
|||
return doPost("/api/asset", asset, Asset.class); |
|||
} |
|||
|
|||
private void verifyTelemetry(EntityId entityId, Map<String, String> expectedResults) throws Exception { |
|||
ObjectNode result = getLatestTelemetry(entityId, expectedResults.keySet().toArray(new String[0])); |
|||
assertThat(result).isNotNull(); |
|||
expectedResults.forEach((key, value) -> assertThat(result.get(key).get(0).get("value").asText()).isEqualTo(value)); |
|||
} |
|||
|
|||
private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { |
|||
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); |
|||
} |
|||
|
|||
private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception { |
|||
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf.ctx.state; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; |
|||
import org.thingsboard.server.common.data.kv.LongDataEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
public class RelatedEntitiesArgumentEntryTest { |
|||
|
|||
private RelatedEntitiesArgumentEntry entry; |
|||
|
|||
private final DeviceId device1 = new DeviceId(UUID.fromString("1984e5f4-9ff0-4187-84ae-e4438bba4c8a")); |
|||
private final DeviceId device2 = new DeviceId(UUID.fromString("937fc062-1a9d-438f-aa22-55a93fc908b7")); |
|||
|
|||
private final long ts = System.currentTimeMillis(); |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
Map<EntityId, ArgumentEntry> aggInputs = new HashMap<>(); |
|||
aggInputs.put(device1, new SingleValueArgumentEntry(device1, new BasicTsKvEntry(ts - 100, new LongDataEntry("key", 12L), 1L))); |
|||
aggInputs.put(device2, new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 150, new LongDataEntry("key", 16L), 6L))); |
|||
|
|||
entry = new RelatedEntitiesArgumentEntry(aggInputs, false); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenNotAggEntryPassed() { |
|||
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenAggArgumentEntryPasser() { |
|||
DeviceId device3 = new DeviceId(UUID.randomUUID()); |
|||
DeviceId device4 = new DeviceId(UUID.randomUUID()); |
|||
|
|||
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = new RelatedEntitiesArgumentEntry(Map.of( |
|||
device3, new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 16L), 13L)), |
|||
device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L)) |
|||
), false); |
|||
|
|||
assertThat(entry.updateEntry(relatedEntitiesArgumentEntry)).isTrue(); |
|||
|
|||
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs(); |
|||
assertThat(aggInputs.size()).isEqualTo(4); |
|||
assertThat(aggInputs.get(device3)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device3)); |
|||
assertThat(aggInputs.get(device4)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device4)); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndNoEntriesById() { |
|||
DeviceId device3 = new DeviceId(UUID.randomUUID()); |
|||
|
|||
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); |
|||
|
|||
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); |
|||
|
|||
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs(); |
|||
assertThat(aggInputs.size()).isEqualTo(3); |
|||
assertThat(aggInputs.get(device3)).isEqualTo(singleEntityArgumentEntry); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() { |
|||
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); |
|||
|
|||
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); |
|||
|
|||
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs(); |
|||
assertThat(aggInputs.size()).isEqualTo(2); |
|||
assertThat(aggInputs.get(device2)).isEqualTo(singleEntityArgumentEntry); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf.configuration.aggregation; |
|||
|
|||
public enum AggFunction { |
|||
MIN, MAX, SUM, AVG, COUNT, COUNT_UNIQUE |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf.configuration.aggregation; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
public class AggFunctionInput implements AggInput { |
|||
|
|||
private String function; |
|||
|
|||
@Override |
|||
public String getType() { |
|||
return "function"; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf.configuration.aggregation; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonIgnore; |
|||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
|||
import com.fasterxml.jackson.annotation.JsonSubTypes; |
|||
import com.fasterxml.jackson.annotation.JsonTypeInfo; |
|||
|
|||
@JsonTypeInfo( |
|||
use = JsonTypeInfo.Id.NAME, |
|||
include = JsonTypeInfo.As.PROPERTY, |
|||
property = "type" |
|||
) |
|||
@JsonSubTypes({ |
|||
@JsonSubTypes.Type(value = AggKeyInput.class, name = "key"), |
|||
@JsonSubTypes.Type(value = AggFunctionInput.class, name = "function") |
|||
}) |
|||
@JsonIgnoreProperties(ignoreUnknown = true) |
|||
public interface AggInput { |
|||
|
|||
@JsonIgnore |
|||
String getType(); |
|||
|
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf.configuration.aggregation; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
public class AggKeyInput implements AggInput { |
|||
|
|||
private String key; |
|||
|
|||
@Override |
|||
public String getType() { |
|||
return "key"; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf.configuration.aggregation; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
@JsonIgnoreProperties(ignoreUnknown = true) |
|||
public class AggMetric { |
|||
|
|||
private AggFunction function; |
|||
private String filter; |
|||
private AggInput input; |
|||
|
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.cf.configuration.aggregation; |
|||
|
|||
import jakarta.validation.Valid; |
|||
import jakarta.validation.constraints.NotEmpty; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.Argument; |
|||
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.cf.configuration.Output; |
|||
import org.thingsboard.server.common.data.relation.RelationPathLevel; |
|||
|
|||
import java.util.Map; |
|||
|
|||
@Data |
|||
public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { |
|||
|
|||
@NotNull |
|||
private RelationPathLevel relation; |
|||
private Map<String, Argument> arguments; |
|||
private long deduplicationIntervalInSec; |
|||
@Valid |
|||
@NotEmpty |
|||
private Map<String, AggMetric> metrics; |
|||
private Output output; |
|||
private boolean useLatestTs; |
|||
|
|||
@Override |
|||
public CalculatedFieldType getType() { |
|||
return CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; |
|||
} |
|||
|
|||
@Override |
|||
public void validate() { |
|||
relation.validate(); |
|||
if (arguments.containsKey("ctx")) { |
|||
throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used."); |
|||
} |
|||
if (arguments.values().stream().anyMatch(Argument::hasTsRollingArgument)) { |
|||
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support TS_ROLLING arguments."); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
/** |
|||
* Copyright © 2016-2025 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.script.api.tbel; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonCreator; |
|||
import com.fasterxml.jackson.annotation.JsonProperty; |
|||
import lombok.Data; |
|||
|
|||
import java.util.Collections; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
@Data |
|||
public class TbelCfRelatedEntitiesArgumentValue implements TbelCfArg { |
|||
|
|||
private final Map<UUID, TbelCfSingleValueArg> entityInputs; |
|||
|
|||
@JsonCreator |
|||
public TbelCfRelatedEntitiesArgumentValue(@JsonProperty("entityInputs") Map<UUID, TbelCfSingleValueArg> values) { |
|||
this.entityInputs = Collections.unmodifiableMap(values); |
|||
} |
|||
|
|||
@Override |
|||
public String getType() { |
|||
return "RELATED_ENTITIES_ARGUMENT_VALUE"; |
|||
} |
|||
|
|||
@Override |
|||
public long memorySize() { |
|||
return OBJ_SIZE; |
|||
} |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { ChangeDetectorRef, Component, DestroyRef, forwardRef, Renderer2, ViewContainerRef, } from '@angular/core'; |
|||
import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; |
|||
import { TbPopoverService } from '@shared/components/popover.service'; |
|||
import { EntityService } from '@core/http/entity.service'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { |
|||
CalculatedFieldArgumentsTableComponent |
|||
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; |
|||
import { ArgumentEntityType } from '@shared/models/calculated-field.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-related-aggregation-arguments-table', |
|||
templateUrl: './calculated-field-arguments-table.component.html', |
|||
styleUrls: [`calculated-field-arguments-table.component.scss`], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
}) |
|||
export class RelatedAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { |
|||
|
|||
constructor( |
|||
protected fb: FormBuilder, |
|||
protected popoverService: TbPopoverService, |
|||
protected viewContainerRef: ViewContainerRef, |
|||
protected cd: ChangeDetectorRef, |
|||
protected renderer: Renderer2, |
|||
protected entityService: EntityService, |
|||
protected destroyRef: DestroyRef, |
|||
protected store: Store<AppState> |
|||
) { |
|||
super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store); |
|||
|
|||
this.argumentNameColumn = 'calculated-fields.argument-name'; |
|||
this.displayColumns = ['name', 'type', 'key', 'actions']; |
|||
this.panelAdditionalCtx = { |
|||
hiddenEntityTypes: true, |
|||
defaultValueRequired: true, |
|||
argumentEntityTypes: [ArgumentEntityType.Current], |
|||
hint: 'calculated-fields.hint.setting-arguments-aggregation' |
|||
}; |
|||
|
|||
this.isScript = false; |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
@import '../../../../../../../scss/constants'; |
|||
|
|||
:host { |
|||
.tb-config-panel { |
|||
width: 520px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
@media #{$mat-lt-md} { |
|||
max-width: fit-content; |
|||
} |
|||
@media #{$mat-xs} { |
|||
width: 90vw; |
|||
} |
|||
|
|||
.tb-config-panel-title { |
|||
line-height: 24px; |
|||
letter-spacing: 0.25px; |
|||
color: rgba(0, 0, 0, 0.87); |
|||
font-weight: 500; |
|||
font-size: 16px; |
|||
} |
|||
|
|||
.tb-config-panel-content { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
overflow: auto; |
|||
|
|||
.fixed-title-width { |
|||
@media #{$mat-xs} { |
|||
min-width: 120px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.tb-config-panel-buttons { |
|||
height: 40px; |
|||
display: flex; |
|||
flex-direction: row; |
|||
gap: 16px; |
|||
justify-content: flex-end; |
|||
align-items: flex-end; |
|||
} |
|||
} |
|||
} |
|||
@ -1,76 +0,0 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host { |
|||
.arguments-table { |
|||
min-height: 108px; |
|||
|
|||
&-with-error { |
|||
min-height: 150px; |
|||
} |
|||
|
|||
.mat-mdc-table { |
|||
table-layout: fixed; |
|||
} |
|||
|
|||
.key-text { |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.copy-argument-name { |
|||
visibility: hidden; |
|||
transition: visibility 0.1s; |
|||
} |
|||
|
|||
.argument-name-cell:hover { |
|||
.copy-argument-name { |
|||
visibility: visible; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.max-args-warning { |
|||
.mat-icon { |
|||
color: #FAA405; |
|||
} |
|||
} |
|||
|
|||
.tb-form-table-row-cell-buttons { |
|||
--mat-badge-legacy-small-size-container-size: 8px; |
|||
--mat-badge-small-size-container-overlap-offset: -5px; |
|||
--mat-badge-small-size-text-size: 0; |
|||
} |
|||
} |
|||
|
|||
:host ::ng-deep { |
|||
.arguments-table:not(.arguments-table-with-error) { |
|||
.mdc-data-table__row:last-child .mat-mdc-cell { |
|||
border-bottom: none; |
|||
} |
|||
} |
|||
|
|||
.arguments-table { |
|||
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { |
|||
padding: 0 28px 0 0; |
|||
} |
|||
} |
|||
|
|||
.copy-argument-name { |
|||
.mat-icon { |
|||
font-size: 16px; |
|||
padding: 4px; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,175 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2025 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="tb-config-panel" [formGroup]="metricForm"> |
|||
<div class="tb-config-panel-title">{{ 'calculated-fields.metrics.metric-settings' | translate }}</div> |
|||
<div class="tb-config-panel-content tb-form-panel no-border no-padding"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.metrics.metric-name' | translate }}</div> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput autocomplete="new-name" name="value" formControlName="name" maxlength="255" |
|||
placeholder="{{ 'action.set' | translate }}"/> |
|||
@if (metricForm.get('name').touched && metricForm.get('name').hasError('required')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-required' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('duplicateName')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-duplicate' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('pattern')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-pattern' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('maxlength')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-max-length' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('forbiddenName')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-forbidden' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.metrics.aggregation' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="function"> |
|||
@for (aggFunction of AggFunctions; track aggFunction) { |
|||
<mat-option [value]="aggFunction">{{ AggFunctionTranslations.get(aggFunction) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
|
|||
<div class="tb-form-panel stroked tb-slide-toggle"> |
|||
<mat-expansion-panel class="tb-settings" [(expanded)]="filterExpanded" |
|||
[disabled]="!metricForm.get('allowFilter').value"> |
|||
<mat-expansion-panel-header class="flex flex-row flex-wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="allowFilter" |
|||
(click)="$event.stopPropagation()"> |
|||
<div tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.filter-hint' | translate }}"> |
|||
{{ 'calculated-fields.metrics.filter' | translate }} |
|||
</div> |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<tb-js-func required |
|||
formControlName="filter" |
|||
functionName="filter" |
|||
[functionArgs]="functionArgs" |
|||
[disableUndefinedCheck]="true" |
|||
[scriptLanguage]="ScriptLanguage.TBEL" |
|||
[highlightRules]="highlightRules" |
|||
[editorCompleter]="editorCompleter" |
|||
[helpPopupStyle]="{ width: '1200px' }" |
|||
helpId="calculated-field/filter_expression_fn"> |
|||
<div toolbarPrefixButton |
|||
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }} |
|||
</div> |
|||
</tb-js-func> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<ng-container formGroupName="input"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="type"> |
|||
@for (inputType of AggInputTypes; track inputType) { |
|||
<mat-option [value]="inputType">{{ AggInputTypeTranslations.get(inputType) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
@if (this.metricForm.get('input.type').value === AggInputType.key) { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="key" placeholder="{{ 'action.set' | translate }}"> |
|||
@for (argument of arguments; track argument) { |
|||
<mat-option [value]="argument">{{ argument }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
@if (metricForm.get('input.key').touched && metricForm.get('input.key').hasError('required')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate" |
|||
class="tb-error !block"> |
|||
warning |
|||
</mat-icon> |
|||
} |
|||
</mat-form-field> |
|||
</div> |
|||
} @else { |
|||
<tb-js-func required |
|||
formControlName="function" |
|||
functionName="filter" |
|||
[functionArgs]="functionArgs" |
|||
[disableUndefinedCheck]="true" |
|||
[scriptLanguage]="ScriptLanguage.TBEL" |
|||
[highlightRules]="highlightRules" |
|||
[editorCompleter]="editorCompleter" |
|||
[helpPopupStyle]="{ width: '1200px' }" |
|||
helpId="calculated-field/expression_fn"> |
|||
<div toolbarPrefixButton |
|||
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }} |
|||
</div> |
|||
</tb-js-func> |
|||
} |
|||
</ng-container> |
|||
</div> |
|||
<div class="tb-config-panel-buttons"> |
|||
<button mat-button |
|||
color="primary" |
|||
type="button" |
|||
(click)="cancel()"> |
|||
{{ 'action.cancel' | translate }} |
|||
</button> |
|||
<button mat-raised-button |
|||
color="primary" |
|||
type="button" |
|||
(click)="saveMetric()" |
|||
[disabled]="metricForm.invalid || !metricForm.dirty"> |
|||
{{ buttonTitle | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,167 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, Input, OnInit, output } from '@angular/core'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { FormBuilder, FormControl, ValidatorFn, Validators } from '@angular/forms'; |
|||
import { charsWithNumRegex } from '@shared/models/regex.constants'; |
|||
import { |
|||
AggFunction, |
|||
AggFunctionTranslations, |
|||
AggInputType, |
|||
AggInputTypeTranslations, |
|||
CalculatedFieldAggMetricValue |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { EntityFilter } from '@shared/models/query/query.models'; |
|||
import { ScriptLanguage } from '@shared/models/rule-node.models'; |
|||
import { TbEditorCompleter } from '@shared/models/ace/completion.models'; |
|||
import { AceHighlightRules } from '@shared/models/ace/ace.models'; |
|||
|
|||
interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue { |
|||
allowFilter: boolean; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-calculated-field-metrics-panel', |
|||
templateUrl: './calculated-field-metrics-panel.component.html', |
|||
styleUrl: '../common/calculated-field-panel.scss', |
|||
}) |
|||
export class CalculatedFieldMetricsPanelComponent implements OnInit { |
|||
|
|||
@Input() buttonTitle: string; |
|||
@Input() metric: CalculatedFieldAggMetricValue; |
|||
@Input() usedNames: string[]; |
|||
@Input() arguments: Array<string>; |
|||
@Input() editorCompleter: TbEditorCompleter; |
|||
@Input() highlightRules: AceHighlightRules; |
|||
|
|||
metricDataApplied = output<CalculatedFieldAggMetricValue>(); |
|||
filterExpanded = false; |
|||
functionArgs: Array<string> |
|||
|
|||
metricForm = this.fb.group({ |
|||
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], |
|||
function: [AggFunction.AVG], |
|||
allowFilter: [false], |
|||
filter: ['', Validators.required], |
|||
input: this.fb.group({ |
|||
type: [AggInputType.key], |
|||
key: ['', Validators.required], |
|||
function: ['', Validators.required], |
|||
}) |
|||
}); |
|||
|
|||
entityFilter: EntityFilter; |
|||
|
|||
readonly AggFunctions = Object.values(AggFunction) as AggFunction[]; |
|||
readonly AggFunctionTranslations = AggFunctionTranslations; |
|||
readonly ScriptLanguage = ScriptLanguage; |
|||
readonly AggInputType = AggInputType; |
|||
readonly AggInputTypes = Object.values(AggInputType) as AggInputType[]; |
|||
readonly AggInputTypeTranslations = AggInputTypeTranslations; |
|||
|
|||
constructor( |
|||
private fb: FormBuilder, |
|||
private popover: TbPopoverComponent<CalculatedFieldMetricsPanelComponent> |
|||
) { |
|||
this.observeFilterAllowChange(); |
|||
this.observeInputTypeChange(); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
const data: CalculatedFieldAggMetricValuePanel = { |
|||
...this.metric, |
|||
allowFilter: !!this.metric.filter, |
|||
} |
|||
this.metricForm.patchValue(data, {emitEvent: false}); |
|||
|
|||
this.validateFilter(data.allowFilter); |
|||
this.validateInputTypeFilter(data.input?.type ?? AggInputType.key); |
|||
this.validateInputKey(); |
|||
|
|||
this.functionArgs = ['ctx', ...this.arguments]; |
|||
} |
|||
|
|||
saveMetric(): void { |
|||
const value = this.metricForm.value as CalculatedFieldAggMetricValuePanel; |
|||
if (!value.allowFilter) { |
|||
delete value.filter; |
|||
} |
|||
delete value.allowFilter; |
|||
this.metricDataApplied.emit(value); |
|||
} |
|||
|
|||
cancel(): void { |
|||
this.popover.hide(); |
|||
} |
|||
|
|||
private observeFilterAllowChange(): void { |
|||
this.metricForm.get('allowFilter').valueChanges |
|||
.pipe(takeUntilDestroyed()) |
|||
.subscribe(value => this.validateFilter(value)); |
|||
} |
|||
|
|||
private observeInputTypeChange(): void { |
|||
this.metricForm.get('input.type').valueChanges |
|||
.pipe(takeUntilDestroyed()) |
|||
.subscribe(value => this.validateInputTypeFilter(value)); |
|||
} |
|||
|
|||
private validateFilter(allowFilter = false): void { |
|||
if (allowFilter) { |
|||
this.metricForm.get('filter').enable({emitEvent: false}); |
|||
} else { |
|||
this.metricForm.get('filter').disable({emitEvent: false}); |
|||
} |
|||
this.filterExpanded = allowFilter; |
|||
} |
|||
|
|||
private validateInputTypeFilter(value: AggInputType): void { |
|||
const inputForm = this.metricForm.get('input'); |
|||
if (value === AggInputType.key) { |
|||
inputForm.get('key').enable({emitEvent: false}); |
|||
inputForm.get('function').disable({emitEvent: false}); |
|||
} else { |
|||
inputForm.get('key').disable({emitEvent: false}); |
|||
inputForm.get('function').enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
private validateInputKey() { |
|||
if (this.metric.input?.type === AggInputType.key && !this.arguments.includes(this.metric.input.key)) { |
|||
this.metricForm.get('input.key').setValue(null); |
|||
this.metricForm.get('input.key').markAsTouched(); |
|||
} |
|||
} |
|||
|
|||
private uniqNameRequired(): ValidatorFn { |
|||
return (control: FormControl) => { |
|||
const newName = control.value.trim().toLowerCase(); |
|||
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName); |
|||
|
|||
return isDuplicate ? { duplicateName: true } : null; |
|||
}; |
|||
} |
|||
|
|||
private forbiddenNameValidator(): ValidatorFn { |
|||
return (control: FormControl) => { |
|||
const trimmedValue = control.value.trim().toLowerCase(); |
|||
const forbiddenNames = ['ctx', 'e', 'pi']; |
|||
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null; |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2025 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="flex flex-col gap-3"> |
|||
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText"> |
|||
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort |
|||
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> |
|||
<ng-container [matColumnDef]="'name'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/5 xs:!w-full sm:!w-1/2"> |
|||
<div tbTruncateWithTooltip>{{ 'calculated-fields.metrics.metric-name' | translate }}</div> |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let metric" class="argument-name-cell w-1/5 xs:w-full sm:w-1/2"> |
|||
<div class="flex items-center"> |
|||
<div tbTruncateWithTooltip class="flex-1">{{ metric.name }}</div> |
|||
<tb-copy-button class="copy-argument-name" |
|||
[copyText]="metric.name" |
|||
tooltipText="{{ 'calculated-fields.metrics.copy-metric-name' | translate }}" |
|||
tooltipPosition="above" |
|||
icon="content_copy"/> |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container [matColumnDef]="'function'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 xs:hidden lt-md:w-1/2"> |
|||
{{ 'calculated-fields.metrics.aggregation' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let metric" class="w-1/5 xs:hidden lt-md:w-1/2"> |
|||
<div tbTruncateWithTooltip>{{ AggFunctionTranslations.get(metric.function) | translate }}</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container [matColumnDef]="'filter'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 lt-md:hidden"> |
|||
{{ 'calculated-fields.metrics.filtered' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let metric" class="w-1/5 lt-md:hidden"> |
|||
<div> |
|||
<mat-icon class="ml-4 align-middle">{{ metric.filter ? 'check_box' : 'check_box_outline_blank' }}</mat-icon> |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container [matColumnDef]="'valueSource'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-2/5 lt-md:hidden"> |
|||
{{ 'calculated-fields.metrics.value-source' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let metric" class="w-2/5 lt-md:hidden"> |
|||
<div tbTruncateWithTooltip>{{ AggInputTypeTranslations.get(metric.input.type) | translate }}</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
|
|||
<ng-container matColumnDef="actions" stickyEnd> |
|||
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/> |
|||
<mat-cell *matCellDef="let metric;"> |
|||
<div class="tb-form-table-row-cell-buttons min-w-20"> |
|||
<button type="button" |
|||
mat-icon-button |
|||
#button |
|||
(click)="manageMetrics($event, button, metric)" |
|||
[matTooltip]="'action.edit' | translate" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>edit</mat-icon> |
|||
</button> |
|||
<button type="button" |
|||
mat-icon-button |
|||
(click)="onDelete($event, metric)" |
|||
[matTooltip]="'action.delete' | translate" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<mat-header-row class="mat-row-select" *matHeaderRowDef=displayColumns></mat-header-row> |
|||
<mat-row *matRowDef="let argument; columns: displayColumns"></mat-row> |
|||
</table> |
|||
<div [class.!hidden]="(dataSource.isEmpty() | async) === false" |
|||
class="tb-prompt flex flex-1 items-end justify-center"> |
|||
{{ 'calculated-fields.metrics.no-metrics-configured' | translate }} |
|||
</div> |
|||
@if (errorText) { |
|||
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/> |
|||
} |
|||
</div> |
|||
<div class="flex h-9 justify-between"> |
|||
<button type="button" |
|||
mat-stroked-button |
|||
color="primary" |
|||
#button |
|||
(click)="manageMetrics($event, button)" |
|||
[disabled]="maxArgumentsPerCF > 0 && metricsFormArray.length >= maxArgumentsPerCF"> |
|||
{{ 'calculated-fields.metrics.add-metric' | translate }} |
|||
</button> |
|||
@if (maxArgumentsPerCF && metricsFormArray.length >= maxArgumentsPerCF) { |
|||
<div class="tb-form-hint tb-primary-fill max-args-warning flex items-center gap-2"> |
|||
<mat-icon>warning</mat-icon> |
|||
<span>{{ 'calculated-fields.metrics.max-metrics' | translate }}</span> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,244 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
AfterViewInit, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
DestroyRef, |
|||
forwardRef, |
|||
Input, |
|||
Renderer2, |
|||
ViewChild, |
|||
ViewContainerRef, |
|||
} from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
FormBuilder, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
ValidationErrors, |
|||
Validator, |
|||
} from '@angular/forms'; |
|||
import { |
|||
AggFunctionTranslations, |
|||
AggInputTypeTranslations, |
|||
CalculatedFieldAggMetric, |
|||
CalculatedFieldAggMetricValue, |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { MatButton } from '@angular/material/button'; |
|||
import { TbPopoverService } from '@shared/components/popover.service'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { isDefinedAndNotNull, isEqual } from '@core/utils'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; |
|||
import { MatSort, SortDirection } from '@angular/material/sort'; |
|||
import { getCurrentAuthState } from '@core/auth/auth.selectors'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { |
|||
CalculatedFieldMetricsPanelComponent |
|||
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component'; |
|||
import { TbEditorCompleter } from '@shared/models/ace/completion.models'; |
|||
import { AceHighlightRules } from '@shared/models/ace/ace.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-calculated-field-metrics-table', |
|||
templateUrl: './calculated-field-metrics-table.component.html', |
|||
styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
}) |
|||
export class CalculatedFieldMetricsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { |
|||
|
|||
@Input() arguments: Array<string>; |
|||
@Input() editorCompleter: TbEditorCompleter; |
|||
@Input() highlightRules: AceHighlightRules; |
|||
|
|||
@ViewChild(MatSort, { static: true }) sort: MatSort; |
|||
|
|||
errorText = ''; |
|||
metricsFormArray = this.fb.array<CalculatedFieldAggMetricValue>([]); |
|||
sortOrder = { direction: 'asc' as SortDirection, property: '' }; |
|||
dataSource = new CalculatedFieldMetricsDatasource(); |
|||
|
|||
displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions'] |
|||
|
|||
readonly AggFunctionTranslations = AggFunctionTranslations; |
|||
readonly AggInputTypeTranslations = AggInputTypeTranslations; |
|||
readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2; |
|||
|
|||
private popoverComponent: TbPopoverComponent<CalculatedFieldMetricsPanelComponent>; |
|||
private propagateChange: (zonesObj: Record<string, CalculatedFieldAggMetric>) => void = () => {}; |
|||
|
|||
constructor( |
|||
private fb: FormBuilder, |
|||
private popoverService: TbPopoverService, |
|||
private viewContainerRef: ViewContainerRef, |
|||
private cd: ChangeDetectorRef, |
|||
private renderer: Renderer2, |
|||
private destroyRef: DestroyRef, |
|||
private store: Store<AppState> |
|||
) { |
|||
this.metricsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { |
|||
this.updateDataSource(value); |
|||
this.propagateChange(this.getMetricsObject(value)); |
|||
}); |
|||
} |
|||
|
|||
ngAfterViewInit(): void { |
|||
this.sort.sortChange.asObservable().pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.sortOrder.property = this.sort.active; |
|||
this.sortOrder.direction = this.sort.direction; |
|||
this.updateDataSource(this.metricsFormArray.value); |
|||
}); |
|||
} |
|||
|
|||
registerOnChange(fn: (zonesObj: Record<string, CalculatedFieldAggMetric>) => void): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(_fn: any): void {} |
|||
|
|||
validate(): ValidationErrors | null { |
|||
this.updateErrorText(); |
|||
return this.errorText ? { metricsFormArray: false } : null; |
|||
} |
|||
|
|||
onDelete($event: Event, metric: CalculatedFieldAggMetricValue): void { |
|||
$event.stopPropagation(); |
|||
const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); |
|||
this.metricsFormArray.removeAt(index); |
|||
this.metricsFormArray.markAsDirty(); |
|||
} |
|||
|
|||
manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue): void { |
|||
$event?.stopPropagation(); |
|||
if (this.popoverComponent && !this.popoverComponent.tbHidden) { |
|||
this.popoverComponent.hide(); |
|||
} |
|||
const trigger = matButton._elementRef.nativeElement; |
|||
if (this.popoverService.hasPopover(trigger)) { |
|||
this.popoverService.hidePopover(trigger); |
|||
} else { |
|||
const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); |
|||
const isExists = index !== -1; |
|||
const ctx = { |
|||
index, |
|||
metric, |
|||
buttonTitle: isExists ? 'action.apply' : 'action.add', |
|||
usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name), |
|||
arguments: this.arguments, |
|||
editorCompleter: this.editorCompleter, |
|||
highlightRules: this.highlightRules |
|||
}; |
|||
this.popoverComponent = this.popoverService.displayPopover({ |
|||
trigger, |
|||
renderer: this.renderer, |
|||
componentType: CalculatedFieldMetricsPanelComponent, |
|||
hostView: this.viewContainerRef, |
|||
preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], |
|||
context: ctx, |
|||
isModal: true |
|||
}); |
|||
this.popoverComponent.tbComponentRef.instance.metricDataApplied.subscribe((value) => { |
|||
this.popoverComponent.hide(); |
|||
if (isExists) { |
|||
this.metricsFormArray.at(index).setValue(value); |
|||
} else { |
|||
this.metricsFormArray.push(this.fb.control(value)); |
|||
} |
|||
this.cd.markForCheck(); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private updateDataSource(value: CalculatedFieldAggMetricValue[]): void { |
|||
const sortedValue = this.sortData(value); |
|||
this.dataSource.loadData(sortedValue); |
|||
} |
|||
|
|||
private updateErrorText(): void { |
|||
if (!this.metricsFormArray.controls.length) { |
|||
this.errorText = 'calculated-fields.metrics.metrics-empty'; |
|||
} else { |
|||
this.errorText = ''; |
|||
} |
|||
} |
|||
|
|||
private getMetricsObject(value: CalculatedFieldAggMetricValue[]): Record<string, CalculatedFieldAggMetric> { |
|||
return value.reduce((acc, metricValue) => { |
|||
const { name, ...metric } = metricValue; |
|||
acc[name] = metric; |
|||
return acc; |
|||
}, {} as Record<string, CalculatedFieldAggMetric>); |
|||
} |
|||
|
|||
writeValue(metrics: Record<string, CalculatedFieldAggMetric>): void { |
|||
this.metricsFormArray.clear(); |
|||
this.populateZonesFormArray(metrics); |
|||
} |
|||
|
|||
private populateZonesFormArray(metrics: Record<string, CalculatedFieldAggMetric>): void { |
|||
Object.keys(metrics).forEach(key => { |
|||
const value: CalculatedFieldAggMetricValue = { |
|||
...metrics[key], |
|||
name: key |
|||
}; |
|||
this.metricsFormArray.push(this.fb.control(value), { emitEvent: false }); |
|||
}); |
|||
this.metricsFormArray.updateValueAndValidity(); |
|||
} |
|||
|
|||
private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string { |
|||
switch (column) { |
|||
case 'function': |
|||
return metric.function; |
|||
case 'valueSource': |
|||
return metric.input?.type; |
|||
case 'filter': |
|||
return isDefinedAndNotNull(metric.filter).toString(); |
|||
default: |
|||
return metric.name; |
|||
} |
|||
} |
|||
|
|||
private sortData(data: CalculatedFieldAggMetricValue[]): CalculatedFieldAggMetricValue[] { |
|||
return data.sort((a, b) => { |
|||
const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; |
|||
const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; |
|||
return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
class CalculatedFieldMetricsDatasource extends TbTableDatasource<CalculatedFieldAggMetricValue> { |
|||
constructor() { |
|||
super(); |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2025 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div [formGroup]="relatedAggregationConfiguration" class="tb-form-panel no-border no-padding"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.hint.aggregation-path-related-entities' | translate }}"> |
|||
{{ 'calculated-fields.aggregation-path-related-entities' | translate }} |
|||
</div> |
|||
<div class="flex gap-3 xs:flex-col" formGroupName="relation"> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic" hideRequiredMarker> |
|||
<mat-label>{{ 'calculated-fields.direction' | translate }}</mat-label> |
|||
<mat-select formControlName="direction"> |
|||
@for (direction of Directions; track direction) { |
|||
<mat-option [value]="direction">{{ PropagationDirectionTranslations.get(direction) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)" |
|||
class="flex-1" |
|||
panelWidth="" |
|||
additionalClass="" |
|||
required |
|||
[label]="'calculated-fields.relation-type' | translate" |
|||
[errorText]="'calculated-fields.hint.relation-type-required' | translate" |
|||
formControlName="relationType"> |
|||
</tb-string-autocomplete> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.arguments-aggregation' | translate }}"> |
|||
{{ 'calculated-fields.arguments' | translate }} |
|||
</div> |
|||
<tb-related-aggregation-arguments-table formControlName="arguments" |
|||
[entityId]="entityId" |
|||
[tenantId]="tenantId" |
|||
[entityName]="entityName"/> |
|||
</div> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}"> |
|||
{{ 'calculated-fields.metrics.metrics' | translate }} |
|||
</div> |
|||
<tb-calculated-field-metrics-table formControlName="metrics" |
|||
[arguments]="arguments$ | async" |
|||
[highlightRules]="argumentsHighlightRules$ | async" |
|||
[editorCompleter]="argumentsEditorCompleter$ | async" |
|||
></tb-calculated-field-metrics-table> |
|||
<tb-time-unit-input required |
|||
appearance="outline" |
|||
subscriptSizing="dynamic" |
|||
labelText="{{ 'calculated-fields.deduplication-interval' | translate }}" |
|||
requiredText="{{ 'calculated-fields.deduplication-interval-required' | translate }}" |
|||
minErrorText="{{ 'calculated-fields.deduplication-interval-min' | translate: {sec: minAllowedDeduplicationIntervalInSecForCF} }}" |
|||
[minTime]="minAllowedDeduplicationIntervalInSecForCF" |
|||
formControlName="deduplicationIntervalInSec"> |
|||
</tb-time-unit-input> |
|||
</div> |
|||
<tb-calculate-field-output formControlName="output" [entityId]="entityId" simpleMode hiddenName> |
|||
<div class="tb-form-row simpleMode flex-1"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="useLatestTs"> |
|||
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}"> |
|||
<div translate tbTruncateWithTooltip>calculated-fields.use-latest-timestamp</div> |
|||
</div> |
|||
</mat-slide-toggle> |
|||
</div> |
|||
</tb-calculate-field-output> |
|||
</div> |
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host ::ng-deep { |
|||
.simpleMode { |
|||
min-width: 0; |
|||
|
|||
.mat-slide { |
|||
overflow: hidden; |
|||
|
|||
.mdc-form-field { |
|||
width: 100%; |
|||
|
|||
.mdc-label { |
|||
min-width: 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, forwardRef, Input } from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
FormBuilder, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
ValidationErrors, |
|||
Validator, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
import { Observable, of } from 'rxjs'; |
|||
import { |
|||
CalculatedFieldOutput, |
|||
CalculatedFieldRelatedAggregationConfiguration, |
|||
CalculatedFieldType, |
|||
getCalculatedFieldArgumentsEditorCompleter, |
|||
getCalculatedFieldArgumentsHighlights, |
|||
OutputType, |
|||
PropagationDirectionTranslations |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; |
|||
import { map } from 'rxjs/operators'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { ScriptLanguage } from '@app/shared/models/rule-node.models'; |
|||
import { EntitySearchDirection } from '@shared/models/relation.models'; |
|||
import { getCurrentAuthState } from '@core/auth/auth.selectors'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-related-entities-aggregation-component', |
|||
templateUrl: './related-entities-aggregation-component.component.html', |
|||
styleUrl: './related-entities-aggregation-component.component.scss', |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
}) |
|||
export class RelatedEntitiesAggregationComponentComponent implements ControlValueAccessor, Validator { |
|||
|
|||
@Input({required: true}) |
|||
entityId: EntityId; |
|||
|
|||
@Input({required: true}) |
|||
tenantId: string; |
|||
|
|||
@Input({required: true}) |
|||
entityName: string; |
|||
|
|||
relatedAggregationConfiguration = this.fb.group({ |
|||
relation: this.fb.group({ |
|||
direction: [EntitySearchDirection.FROM, Validators.required], |
|||
relationType: ['Contains', Validators.required], |
|||
}), |
|||
arguments: this.fb.control({}), |
|||
metrics: this.fb.control({}), |
|||
deduplicationIntervalInSec: [], |
|||
output: this.fb.control<CalculatedFieldOutput>({ |
|||
scope: AttributeScope.SERVER_SCOPE, |
|||
type: OutputType.Timeseries, |
|||
}), |
|||
useLatestTs: [false] |
|||
}); |
|||
|
|||
readonly ScriptLanguage = ScriptLanguage; |
|||
readonly CalculatedFieldType = CalculatedFieldType; |
|||
readonly OutputType = OutputType; |
|||
readonly Directions = Object.values(EntitySearchDirection) as Array<EntitySearchDirection>; |
|||
readonly PropagationDirectionTranslations = PropagationDirectionTranslations; |
|||
readonly minAllowedDeduplicationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedDeduplicationIntervalInSecForCF; |
|||
|
|||
|
|||
arguments$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( |
|||
map(argumentsObj => Object.keys(argumentsObj)) |
|||
); |
|||
|
|||
argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( |
|||
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) |
|||
); |
|||
|
|||
argumentsHighlightRules$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( |
|||
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) |
|||
); |
|||
|
|||
private propagateChange: (config: CalculatedFieldRelatedAggregationConfiguration) => void = () => { }; |
|||
|
|||
constructor(private fb: FormBuilder, |
|||
private store: Store<AppState>) { |
|||
|
|||
this.relatedAggregationConfiguration.valueChanges.pipe( |
|||
takeUntilDestroyed() |
|||
).subscribe((value: CalculatedFieldRelatedAggregationConfiguration) => { |
|||
this.updatedModel(value); |
|||
}) |
|||
} |
|||
|
|||
validate(): ValidationErrors | null { |
|||
return this.relatedAggregationConfiguration.valid || this.relatedAggregationConfiguration.status === "DISABLED" ? null : {invalidPropagateConfig: false}; |
|||
} |
|||
|
|||
writeValue(value: CalculatedFieldRelatedAggregationConfiguration): void { |
|||
this.relatedAggregationConfiguration.patchValue(value, {emitEvent: false}); |
|||
setTimeout(() => { |
|||
this.relatedAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); |
|||
}); |
|||
} |
|||
|
|||
registerOnChange(fn: (config: CalculatedFieldRelatedAggregationConfiguration) => void): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(_: any): void { } |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
if (isDisabled) { |
|||
this.relatedAggregationConfiguration.disable({emitEvent: false}); |
|||
} else { |
|||
this.relatedAggregationConfiguration.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
fetchOptions(searchText: string): Observable<Array<string>> { |
|||
const search = searchText ? searchText?.toLowerCase() : ''; |
|||
return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); |
|||
} |
|||
|
|||
private updatedModel(value: CalculatedFieldRelatedAggregationConfiguration): void { |
|||
value.type = CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; |
|||
this.propagateChange(value); |
|||
} |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { NgModule } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { SharedModule } from '@shared/shared.module'; |
|||
import { |
|||
CalculatedFieldOutputModule |
|||
} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; |
|||
import { |
|||
CalculatedFieldArgumentsTableModule |
|||
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; |
|||
import { |
|||
RelatedEntitiesAggregationComponentComponent |
|||
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component'; |
|||
import { |
|||
CalculatedFieldMetricsTableComponent |
|||
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component'; |
|||
import { |
|||
CalculatedFieldMetricsPanelComponent |
|||
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component'; |
|||
|
|||
@NgModule({ |
|||
imports: [ |
|||
CommonModule, |
|||
SharedModule, |
|||
CalculatedFieldOutputModule, |
|||
CalculatedFieldArgumentsTableModule, |
|||
], |
|||
declarations: [ |
|||
RelatedEntitiesAggregationComponentComponent, |
|||
CalculatedFieldMetricsTableComponent, |
|||
CalculatedFieldMetricsPanelComponent |
|||
], |
|||
exports: [ |
|||
RelatedEntitiesAggregationComponentComponent, |
|||
] |
|||
}) |
|||
export class RelatedEntitiesAggregationComponentModule { |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
## Calculated Field TBEL Filter Function |
|||
|
|||
The **filter()** function is a user-defined script that enables custom calculations using [TBEL](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/) on telemetry and attribute data. |
|||
It receives arguments configured in the calculated field setup, along with an additional `ctx` object that stores `latestTs` and provides access to all arguments. |
|||
|
|||
### Function Signature |
|||
|
|||
```javascript |
|||
function calculate(ctx, arg1, arg2, ...): boolean |
|||
``` |
|||
Loading…
Reference in new issue