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