diff --git a/.github/release.yml b/.github/release.yml index 53b2e3ae65..1dad5d274f 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -23,27 +23,19 @@ changelog: labels: - 'Major Core' - 'Major Rule Engine' - exclude: - labels: - - 'Bug' + - title: 'Major UI' labels: - 'Major UI' - exclude: - labels: - - 'Bug' + - title: 'Major Transport' labels: - 'Major Transport' - exclude: - labels: - - 'Bug' + - title: 'Major Edge' labels: - 'Major Edge' - exclude: - labels: - - 'Bug' + - title: 'Core & Rule Engine' labels: - 'Core' @@ -51,38 +43,63 @@ changelog: exclude: labels: - 'Bug' + - title: 'UI' labels: - 'UI' exclude: labels: - 'Bug' + - title: 'Transport' labels: - 'Transport' exclude: labels: - 'Bug' + - title: 'Edge' labels: - 'Edge' exclude: labels: - 'Bug' + - title: 'Bug: Core & Rule Engine' labels: - - 'Core' - - 'Rule Engine' - 'Bug' + exclude: + labels: + - 'UI' + - 'Transport' + - 'Edge' + - title: 'Bug: UI' labels: - - 'UI' - 'Bug' + exclude: + labels: + - 'Core' + - 'Rule Engine' + - 'Transport' + - 'Edge' + - title: 'Bug: Transport' labels: - - 'Transport' - 'Bug' + exclude: + labels: + - 'Core' + - 'Rule Engine' + - 'UI' + - 'Edge' + - title: 'Bug: Edge' labels: - - 'Edge' - 'Bug' + exclude: + labels: + - 'Core' + - 'Rule Engine' + - 'UI' + - 'Transport' diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index d2ab4c85ac..a24c5fb732 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -572,24 +572,24 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } private Map mapToArguments(CalculatedFieldCtx ctx, List data) { - return mapToArguments(entityId, ctx.getMainEntityArguments(), Collections.emptyMap(), data); + return mapToArguments(entityId, ctx, ctx.getMainEntityArguments(), Collections.emptyMap(), data); } private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, List data) { - return mapToArguments(entityId, ctx.getLinkedAndDynamicArgs(entityId), ctx.getRelatedEntityArguments(), data); + return mapToArguments(entityId, ctx, ctx.getLinkedAndDynamicArgs(entityId), ctx.getRelatedEntityArguments(), data); } - private Map mapToArguments(EntityId originator, Map> args, Map> relatedEntityArgs, List data) { + private Map mapToArguments(EntityId originator, CalculatedFieldCtx ctx, Map> args, Map> relatedEntityArgs, List data) { Map arguments = new HashMap<>(); if (!relatedEntityArgs.isEmpty() || !args.isEmpty()) { for (TsKvProto item : data) { ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null); SingleValueArgumentEntry relatedArgIncoming = new SingleValueArgumentEntry(originator, item); - mapLatest(relatedArgIncoming, relatedEntityArgs.get(key), arguments); + mapLatest(ctx, relatedArgIncoming, relatedEntityArgs.get(key), arguments); SingleValueArgumentEntry incoming = new SingleValueArgumentEntry(item); - mapLatest(incoming, args.get(key), arguments); + mapLatest(ctx, incoming, args.get(key), arguments); key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null); mapRolling(item, args.get(key), arguments); @@ -598,7 +598,8 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM return arguments; } - private void mapLatest(SingleValueArgumentEntry incoming, + private void mapLatest(CalculatedFieldCtx ctx, + SingleValueArgumentEntry incoming, Set argNames, Map arguments) { if (argNames != null) { @@ -606,7 +607,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (existing == null) { return incoming; } - existing.updateEntry(incoming); + existing.updateEntry(incoming, ctx); return existing; })); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index dc23ffa979..fc4f8ce365 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -52,7 +52,7 @@ public interface ArgumentEntry { Object getValue(); - boolean updateEntry(ArgumentEntry entry); + boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx); boolean isEmpty(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 09ca35cc4b..0f648600a5 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -86,13 +86,13 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, validateNewEntry(key, newEntry); if (existingEntry instanceof RelatedEntitiesArgumentEntry || existingEntry instanceof EntityAggregationArgumentEntry) { - updateEntry(existingEntry, newEntry); + updateEntry(existingEntry, newEntry, ctx); } else { arguments.put(key, newEntry); } entryUpdated = true; } else { - entryUpdated = updateEntry(existingEntry, newEntry); + entryUpdated = updateEntry(existingEntry, newEntry, ctx); } if (entryUpdated) { @@ -111,8 +111,8 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, return updatedArguments; } - protected boolean updateEntry(ArgumentEntry existingEntry, ArgumentEntry newEntry) { - return existingEntry.updateEntry(newEntry); + protected boolean updateEntry(ArgumentEntry existingEntry, ArgumentEntry newEntry, CalculatedFieldCtx ctx) { + return existingEntry.updateEntry(newEntry, ctx); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java index 4d0c4d7724..028c3c429b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -159,7 +159,7 @@ public class SingleValueArgumentEntry implements ArgumentEntry { } @Override - public boolean updateEntry(ArgumentEntry entry) { + public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) { if (entry instanceof SingleValueArgumentEntry singleValueEntry) { if (singleValueEntry.getTs() < this.ts) { return false; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java index 8cdc9ddcf9..8abddb3d4a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java @@ -100,7 +100,7 @@ public class TsRollingArgumentEntry implements ArgumentEntry, HasLatestTs { } @Override - public boolean updateEntry(ArgumentEntry entry) { + public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) { if (entry instanceof TsRollingArgumentEntry tsRollingEntry) { updateTsRollingEntry(tsRollingEntry); } else if (entry instanceof SingleValueArgumentEntry singleValueEntry) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java index 219cf471ed..0a5c850e4f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java @@ -23,6 +23,7 @@ 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.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.HasLatestTs; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; @@ -63,7 +64,7 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs } @Override - public boolean updateEntry(ArgumentEntry entry) { + public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) { if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs); return true; @@ -74,7 +75,7 @@ public class RelatedEntitiesArgumentEntry implements ArgumentEntry, HasLatestTs } ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId()); if (argumentEntry != null) { - argumentEntry.updateEntry(singleValueArgumentEntry); + argumentEntry.updateEntry(singleValueArgumentEntry, ctx); } else { entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java index 7ec5098bc3..c0a0603390 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java @@ -19,11 +19,18 @@ import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; 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.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import java.time.Instant; +import java.time.ZonedDateTime; import java.util.Map; +import java.util.concurrent.TimeUnit; @Data public class EntityAggregationArgumentEntry implements ArgumentEntry { @@ -47,28 +54,63 @@ public class EntityAggregationArgumentEntry implements ArgumentEntry { } @Override - public boolean updateEntry(ArgumentEntry entry) { - boolean updated = false; + public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) { if (entry instanceof EntityAggregationArgumentEntry entityAggEntry) { aggIntervals.putAll(entityAggEntry.getAggIntervals()); + return true; } else if (entry instanceof SingleValueArgumentEntry singleValueArgEntry) { long entryTs = singleValueArgEntry.getTs(); - long argUpdateTs = System.currentTimeMillis(); - for (Map.Entry aggIntervalEntry : aggIntervals.entrySet()) { - if (singleValueArgEntry.isForceResetPrevious()) { - aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs); - updated = true; - continue; - } - if (aggIntervalEntry.getKey().belongsToInterval(entryTs)) { - aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs); - return true; - } + long now = System.currentTimeMillis(); + if (updateExistingIntervals(singleValueArgEntry, entryTs, now)) { + return true; } + return createNewInterval(entryTs, now, ctx); } + return false; + } + + private boolean updateExistingIntervals(SingleValueArgumentEntry entry, long entryTs, long now) { + boolean updated = false; + + for (Map.Entry aggIntervalEntry : aggIntervals.entrySet()) { + AggIntervalEntry interval = aggIntervalEntry.getKey(); + AggIntervalEntryStatus status = aggIntervalEntry.getValue(); + if (entry.isForceResetPrevious()) { + status.setLastArgsRefreshTs(now); + updated = true; + continue; + } + if (interval.belongsToInterval(entryTs)) { + status.setLastArgsRefreshTs(now); + return true; + } + } + return updated; } + private boolean createNewInterval(long entryTs, long now, CalculatedFieldCtx ctx) { + if (!(ctx.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration config)) { + return false; + } + AggInterval interval = config.getInterval(); + Watermark watermark = config.getWatermark(); + long watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration()); + + ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(entryTs), interval.getZoneId()); + + long startTs = interval.getDateTimeIntervalStartTs(zdt); + long endTs = interval.getDateTimeIntervalEndTs(zdt); + + if (now - endTs > watermarkDuration) { + return false; + } + + AggIntervalEntry newInterval = new AggIntervalEntry(startTs, endTs); + aggIntervals.computeIfAbsent(newInterval, i -> new AggIntervalEntryStatus(now)); + return true; + } + @Override public boolean isEmpty() { return aggIntervals.isEmpty(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index 0f6e01a344..99bddc374c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -154,8 +154,10 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt } private void fillMissingIntervals() { + long now = System.currentTimeMillis(); ZoneId zoneId = interval.getZoneId(); long currentIntervalEndTs = interval.getCurrentIntervalEndTs(); + long watermarkThresholdTs = now - watermarkDuration; Map> intervals = getIntervals(); AggIntervalEntry lastIntervalEntry = intervals.keySet().stream().max(Comparator.comparing(AggIntervalEntry::getEndTs)).orElse(null); @@ -169,6 +171,13 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt while (nextEnd.toInstant().toEpochMilli() <= currentIntervalEndTs) { long nextStartTs = nextStart.toInstant().toEpochMilli(); long nextEndTs = nextEnd.toInstant().toEpochMilli(); + + if (nextEndTs < watermarkThresholdTs) { + nextStart = nextEnd; + nextEnd = interval.getNextIntervalStart(nextStart); + continue; + } + AggIntervalEntry missing = new AggIntervalEntry(nextStartTs, nextEndTs); arguments.forEach((argName, argumentEntry) -> { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 4f2cbede09..0853b923c3 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -150,7 +150,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { clearRuleState = null; } } - log.debug("Initialized create rule states {} and clear rule state {} for {}", createRuleStates, clearRuleState, configuration); + log.debug("Initialized create rule states {} and clear rule state {} for {}. Restored: {}, reeval needed: {}", + createRuleStates, clearRuleState, configuration, restored, reevalNeeded); if (reevalNeeded.get()) { initCurrentAlarm(ctx); @@ -224,17 +225,17 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { } @Override - protected boolean updateEntry(ArgumentEntry existingArgumentEntry, ArgumentEntry newArgumentEntry) { + protected boolean updateEntry(ArgumentEntry existingArgumentEntry, ArgumentEntry newArgumentEntry, CalculatedFieldCtx ctx) { if (!(existingArgumentEntry instanceof SingleValueArgumentEntry existingEntry) || !(newArgumentEntry instanceof SingleValueArgumentEntry newEntry)) { - return super.updateEntry(existingArgumentEntry, newArgumentEntry); + return super.updateEntry(existingArgumentEntry, newArgumentEntry, ctx); } if (newEntry.getTs() < existingEntry.getTs()) { if (existingEntry.isDefaultValue()) { existingEntry.setTs(newEntry.getTs()); } } - return super.updateEntry(existingEntry, newEntry); + return super.updateEntry(existingEntry, newEntry, ctx); } public void processAlarmAction(Alarm alarm, ActionType action) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java index 569bbe9310..cf7b1ce108 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java @@ -262,6 +262,14 @@ public class AlarmRuleState { } } + public void setDurationCheckFuture(ScheduledFuture durationCheckFuture) { + if (this.durationCheckFuture != null) { + log.warn("Setting new duration check future while previous is not null for state {}", this, new RuntimeException("stacktrace")); + this.durationCheckFuture.cancel(true); + } + this.durationCheckFuture = durationCheckFuture; + } + public boolean isEmpty() { return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java index 01c7119993..a3305ea52d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos; 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.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.HasLatestTs; import java.util.Map; @@ -68,7 +69,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry, HasLatestTs { } @Override - public boolean updateEntry(ArgumentEntry entry) { + public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) { if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java index 8536c0f65f..de04d0e817 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java @@ -21,6 +21,7 @@ import org.thingsboard.script.api.tbel.TbelCfPropagationArg; 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.CalculatedFieldCtx; import java.util.ArrayList; import java.util.Collection; @@ -59,7 +60,7 @@ public class PropagationArgumentEntry implements ArgumentEntry { } @Override - public boolean updateEntry(ArgumentEntry entry) { + public boolean updateEntry(ArgumentEntry entry, CalculatedFieldCtx ctx) { if (!(entry instanceof PropagationArgumentEntry updated)) { throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType()); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index e1c7f1198c..4aa7b2b856 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -27,6 +27,7 @@ import com.google.gson.reflect.TypeToken; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode; +import org.thingsboard.rule.engine.ai.TbAiNode; import org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode; import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode; import org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode; @@ -167,6 +168,14 @@ public class EdgeMsgConstructorUtils { ); public static final Map> EXCLUDED_NODES_BY_EDGE_VERSION = Map.of( + EdgeVersion.V_4_1_0, + Set.of( + TbAiNode.class.getName() + ), + EdgeVersion.V_4_0_0, + Set.of( + TbAiNode.class.getName() + ), EdgeVersion.V_3_9_0, Set.of( TbCalculatedFieldsNode.class.getName() @@ -560,7 +569,7 @@ public class EdgeMsgConstructorUtils { .setEntityIdMSB(entityId.getId().getMostSignificantBits()) .setEntityIdLSB(entityId.getId().getLeastSignificantBits()) .setEntityType(entityId.getEntityType().name()); - long ts = getTs(entityData.getAsJsonObject()); + long ts = extractTs(entityData.getAsJsonObject()); switch (actionType) { case TIMESERIES_UPDATED: try { @@ -613,8 +622,8 @@ public class EdgeMsgConstructorUtils { return builder.build(); } - private static long getTs(JsonObject data) { - if (data.get("ts") != null && !data.get("ts").isJsonNull()) { + private static long extractTs(JsonObject data) { + if (data.has("ts") && data.get("ts").isJsonPrimitive()) { return data.getAsJsonPrimitive("ts").getAsLong(); } return System.currentTimeMillis(); @@ -740,7 +749,7 @@ public class EdgeMsgConstructorUtils { result.sort(Comparator.comparingLong(EdgeEvent::getSeqId)); return result; } catch (Exception e) { - log.warn("Can't merge downlink duplicates, edgeEvents [{}]", edgeEvents, e); + log.info("Can't merge downlink duplicates. Sending downlinks without merge. Original edgeEvents [{}]", edgeEvents, e); return edgeEvents; } } @@ -751,6 +760,9 @@ public class EdgeMsgConstructorUtils { } String bodyStr = JacksonUtil.toString(body); var jsonObject = JsonParser.parseString(bodyStr).getAsJsonObject(); + if (!jsonObject.has("ts")) { + return new AttrsTs(0L, List.of()); + } long ts = jsonObject.get("ts").getAsLong(); var kv = jsonObject.getAsJsonObject("kv"); List attrs = JsonConverter.convertToAttributes( @@ -761,22 +773,24 @@ public class EdgeMsgConstructorUtils { } private static JsonNode filterAttributesBody(JsonNode body, Map latestByKey) { - if (body == null || latestByKey == null || latestByKey.isEmpty()) { + if (body == null) { return null; } String bodyStr = JacksonUtil.toString(body); JsonObject jsonObject = JsonParser.parseString(bodyStr).getAsJsonObject(); - long ts = jsonObject.get("ts").getAsLong(); - JsonObject kv = jsonObject.getAsJsonObject("kv"); - for (Iterator> it = kv.entrySet().iterator(); it.hasNext(); ) { - Map.Entry e = it.next(); - Long latestTs = latestByKey.get(e.getKey()); - if (latestTs == null || !latestTs.equals(ts)) { - it.remove(); + if (jsonObject.has("ts") && latestByKey != null && !latestByKey.isEmpty()) { + long ts = jsonObject.get("ts").getAsLong(); + JsonObject kv = jsonObject.getAsJsonObject("kv"); + for (Iterator> it = kv.entrySet().iterator(); it.hasNext(); ) { + Map.Entry e = it.next(); + Long latestTs = latestByKey.get(e.getKey()); + if (latestTs == null || !latestTs.equals(ts)) { + it.remove(); + } + } + if (kv.isEmpty()) { + return null; } - } - if (kv.isEmpty()) { - return null; } return JacksonUtil.toJsonNode(jsonObject.toString()); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index 500ad60df0..d65ac6ba31 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -30,7 +30,8 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.). // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0"); + // TODO: don't check the "patch" number, since upgrade is not required for patch releases + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0", "4.2.1.1", "4.2.1.2"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 787111c28e..63c461dd7c 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1467,7 +1467,7 @@ device: pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}" gateway: # The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch - image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}" + image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}" # Edges parameters edges: diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java index 253d02ebe5..66c11a06a6 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -55,6 +55,8 @@ import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTE @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest { + private final String TZ = "Europe/Kyiv"; + private Tenant savedTenant; @Before @@ -93,7 +95,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception { Device device = createDevice("Device", "1234567890111"); - CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); createConsumptionCF(device.getId(), customInterval, null); long interval = customInterval.getCurrentIntervalDurationMillis(); @@ -113,7 +115,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest public void testCreateCfWithoutWatermark_checkAggregation() throws Exception { Device device = createDevice("Device", "1234567890111"); - CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); createConsumptionCF(device.getId(), customInterval, null); long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); @@ -156,7 +158,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception { Device device = createDevice("Device", "1234567890111"); - CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); Watermark watermark = new Watermark(10); createConsumptionCF(device.getId(), customInterval, watermark); @@ -196,6 +198,51 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest }); } + @Test + public void testSendFutureTelemetry_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 2L); + createConsumptionCF(device.getId(), customInterval, null); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000; + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", currentIntervalStartTs + 4500L)); + + await().alias("update telemetry that belongs to future interval -> check aggregation ") + .atMost(3 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("500"); + assertThat(result.get("consumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("500"); + assertThat(result.get("avgConsumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); + }); + } + private CalculatedField createConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) { Map arguments = new HashMap<>(); Argument argument = new Argument(); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index 724b1c622f..2213bb731c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { private DeviceProfileId mqttDeviceProfileId; private DeviceProfileId coapDeviceProfileId; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Before diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java index 380dec8015..5c3f43b451 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -49,8 +49,6 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.ota.OtaPackageType; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; @@ -671,8 +669,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { .atMost(10, TimeUnit.SECONDS) .until(() -> { String urlTemplate = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/keys/attributes/" + scope; - List actualKeys = doGetAsyncTyped(urlTemplate, new TypeReference<>() { - }); + List actualKeys = doGetAsyncTyped(urlTemplate, new TypeReference<>() {}); return actualKeys != null && !actualKeys.isEmpty() && actualKeys.contains(expectedKey); }); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java index 6da4bdc882..d274da2434 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java @@ -18,6 +18,9 @@ package org.thingsboard.server.service.cf.ctx.state; import io.hypersistence.utils.hibernate.type.json.internal.JacksonUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.geo.PerimeterDefinition; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.EntityId; @@ -33,6 +36,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class GeofencingValueArgumentEntryTest { private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714")); @@ -46,6 +50,9 @@ public class GeofencingValueArgumentEntryTest { private GeofencingArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + @BeforeEach void setUp() { entry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry)); @@ -58,14 +65,14 @@ public class GeofencingValueArgumentEntryTest { @Test void testUpdateEntryWhenSingleEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) + assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry(), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); } @Test void testUpdateEntryWhenRollingEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for geofencing argument entry: TS_ROLLING"); } @@ -74,7 +81,7 @@ public class GeofencingValueArgumentEntryTest { void testUpdateEntryWithTheSameTs() { BaseAttributeKvEntry differentValueSameTs = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 363L, 156L); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueSameTs, ZONE_2_ID, restrictedZoneAttributeKvEntry)); - assertThat(entry.updateEntry(updated)).isFalse(); + assertThat(entry.updateEntry(updated, ctx)).isFalse(); } @Test @@ -83,7 +90,7 @@ public class GeofencingValueArgumentEntryTest { BaseAttributeKvEntry differentValueNewVersionIsNull = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, null); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsNull, ZONE_2_ID, restrictedZoneAttributeKvEntry)); - assertThat(entry.updateEntry(updated)).isTrue(); + assertThat(entry.updateEntry(updated, ctx)).isTrue(); assertThat(entry.getValue()).isInstanceOf(Map.class); Map value = (Map) entry.getValue(); @@ -105,7 +112,7 @@ public class GeofencingValueArgumentEntryTest { BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry)); - assertThat(entry.updateEntry(updated)).isTrue(); + assertThat(entry.updateEntry(updated, ctx)).isTrue(); assertThat(entry.getValue()).isInstanceOf(Map.class); Map value = (Map) entry.getValue(); @@ -126,7 +133,7 @@ public class GeofencingValueArgumentEntryTest { BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 154L); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry)); - assertThat(entry.updateEntry(updated)).isFalse(); + assertThat(entry.updateEntry(updated, ctx)).isFalse(); } @Test @@ -134,7 +141,7 @@ public class GeofencingValueArgumentEntryTest { BaseAttributeKvEntry newTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 364L, 156L); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, newTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry)); - assertThat(entry.updateEntry(updated)).isTrue(); + assertThat(entry.updateEntry(updated, ctx)).isTrue(); } @Test @@ -142,7 +149,7 @@ public class GeofencingValueArgumentEntryTest { BaseAttributeKvEntry oldTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 362L, 156L); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, oldTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry)); - assertThat(entry.updateEntry(updated)).isFalse(); + assertThat(entry.updateEntry(updated, ctx)).isFalse(); } @Test @@ -150,7 +157,7 @@ public class GeofencingValueArgumentEntryTest { final AssetId NEW_ZONE_ID = new AssetId(UUID.fromString("a3eacf1a-6af3-4e9f-87c4-502bb25c7dc3")); BaseAttributeKvEntry newZone = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L); var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry, NEW_ZONE_ID, newZone)); - assertThat(entry.updateEntry(updated)).isTrue(); + assertThat(entry.updateEntry(updated, ctx)).isTrue(); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java index 12f3e4298d..f4098dc2df 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java @@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.script.api.tbel.TbelCfPropagationArg; import org.thingsboard.server.common.data.id.AssetId; @@ -31,6 +34,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class PropagationArgumentEntryTest { private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805")); @@ -39,6 +43,9 @@ public class PropagationArgumentEntryTest { private PropagationArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + @BeforeEach void setUp() { List propagationEntityIds = new ArrayList<>(); @@ -68,14 +75,14 @@ public class PropagationArgumentEntryTest { @Test void testUpdateEntryWhenSingleEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) + assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry(), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE"); } @Test void testUpdateEntryWhenRollingEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING"); } @@ -85,7 +92,7 @@ public class PropagationArgumentEntryTest { var newIds = new ArrayList(List.of(ENTITY_3_ID, ENTITY_1_ID)); var updated = new PropagationArgumentEntry(newIds); - boolean changed = entry.updateEntry(updated); + boolean changed = entry.updateEntry(updated, ctx); assertThat(changed).isTrue(); assertThat(entry.getEntityIds()).containsExactlyElementsOf(newIds); @@ -95,7 +102,7 @@ public class PropagationArgumentEntryTest { void testUpdateEntryClearsWhenNewEntryIsEmpty() { var updatedEmpty = new PropagationArgumentEntry(List.of()); - boolean changed = entry.updateEntry(updatedEmpty); + boolean changed = entry.updateEntry(updatedEmpty, ctx); assertThat(changed).isTrue(); assertThat(entry.getEntityIds()).isEmpty(); @@ -106,7 +113,7 @@ public class PropagationArgumentEntryTest { var added = new PropagationArgumentEntry(); added.setAdded(List.of(ENTITY_3_ID)); - boolean changed = entry.updateEntry(added); + boolean changed = entry.updateEntry(added, ctx); assertThat(changed).isTrue(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID); @@ -118,7 +125,7 @@ public class PropagationArgumentEntryTest { var added = new PropagationArgumentEntry(); added.setAdded(List.of(ENTITY_2_ID)); - boolean changed = entry.updateEntry(added); + boolean changed = entry.updateEntry(added, ctx); assertThat(changed).isFalse(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); @@ -130,7 +137,7 @@ public class PropagationArgumentEntryTest { var removed = new PropagationArgumentEntry(); removed.setRemoved(ENTITY_2_ID); - boolean changed = entry.updateEntry(removed); + boolean changed = entry.updateEntry(removed, ctx); assertThat(changed).isTrue(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID); @@ -142,7 +149,7 @@ public class PropagationArgumentEntryTest { var removed = new PropagationArgumentEntry(); removed.setRemoved(ENTITY_3_ID); - boolean changed = entry.updateEntry(removed); + boolean changed = entry.updateEntry(removed, ctx); assertThat(changed).isFalse(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); @@ -154,7 +161,7 @@ public class PropagationArgumentEntryTest { var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID)); restore.setIgnoreRemovedEntities(true); - boolean changed = entry.updateEntry(restore); + boolean changed = entry.updateEntry(restore, ctx); assertThat(changed).isTrue(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID); @@ -168,7 +175,7 @@ public class PropagationArgumentEntryTest { var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID)); restore.setIgnoreRemovedEntities(true); - boolean changed = entry.updateEntry(restore); + boolean changed = entry.updateEntry(restore, ctx); assertThat(changed).isFalse(); // expected no change, since we consider the removal of stale ids as no-op assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID); @@ -182,7 +189,7 @@ public class PropagationArgumentEntryTest { var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_3_ID)); restore.setIgnoreRemovedEntities(true); - boolean changed = entry.updateEntry(restore); + boolean changed = entry.updateEntry(restore, ctx); assertThat(changed).isTrue(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_3_ID); @@ -197,7 +204,7 @@ public class PropagationArgumentEntryTest { var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_2_ID)); restore.setIgnoreRemovedEntities(true); - boolean changed = entry.updateEntry(restore); + boolean changed = entry.updateEntry(restore, ctx); assertThat(changed).isFalse(); assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); @@ -211,7 +218,7 @@ public class PropagationArgumentEntryTest { var restore = new PropagationArgumentEntry(List.of()); restore.setIgnoreRemovedEntities(true); - boolean changed = entry.updateEntry(restore); + boolean changed = entry.updateEntry(restore, ctx); assertThat(changed).isFalse(); // expected no change, since we consider the removal of stale ids as no-op assertThat(entry.getEntityIds()).isEmpty(); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java index cc60b249ac..c357e2c30b 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java @@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; @@ -30,10 +33,14 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class RelatedEntitiesArgumentEntryTest { private RelatedEntitiesArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + 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")); @@ -50,7 +57,7 @@ public class RelatedEntitiesArgumentEntryTest { @Test void testUpdateEntryWhenNotAggEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING); } @@ -65,7 +72,7 @@ public class RelatedEntitiesArgumentEntryTest { device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L)) ), false); - assertThat(entry.updateEntry(relatedEntitiesArgumentEntry)).isTrue(); + assertThat(entry.updateEntry(relatedEntitiesArgumentEntry, ctx)).isTrue(); Map aggInputs = entry.getEntityInputs(); assertThat(aggInputs.size()).isEqualTo(4); @@ -79,7 +86,7 @@ public class RelatedEntitiesArgumentEntryTest { SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); - assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); + assertThat(entry.updateEntry(singleEntityArgumentEntry, ctx)).isTrue(); Map aggInputs = entry.getEntityInputs(); assertThat(aggInputs.size()).isEqualTo(3); @@ -90,7 +97,7 @@ public class RelatedEntitiesArgumentEntryTest { void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() { SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); - assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); + assertThat(entry.updateEntry(singleEntityArgumentEntry, ctx)).isTrue(); Map aggInputs = entry.getEntityInputs(); assertThat(aggInputs.size()).isEqualTo(2); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java index 4ada355054..e2d287c778 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java @@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; import org.thingsboard.server.common.data.kv.JsonDataEntry; @@ -29,10 +32,14 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class SingleValueArgumentEntryTest { private SingleValueArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + private final long ts = System.currentTimeMillis(); @BeforeEach @@ -47,48 +54,48 @@ public class SingleValueArgumentEntryTest { @Test void testUpdateEntryWhenRollingEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for single value argument entry: " + ArgumentEntryType.TS_ROLLING); } @Test void testUpdateEntryWithTheSameTs() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L))).isFalse(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L), ctx)).isFalse(); } @Test void testUpdateEntryWithTheSameTsAndDifferentVersion() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 364L))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 364L), ctx)).isTrue(); } @Test void testUpdateEntryWhenNewVersionIsNull() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null), ctx)).isTrue(); assertThat(entry.getValue()).isEqualTo(13L); assertThat(entry.getVersion()).isNull(); } @Test void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L), ctx)).isTrue(); assertThat(entry.getValue()).isEqualTo(18L); assertThat(entry.getVersion()).isEqualTo(369L); } @Test void testUpdateEntryWhenNewVersionIsLessThanCurrent() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L))).isFalse(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L), ctx)).isFalse(); } @Test void testUpdateEntryWhenValueWasNotChanged() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L), ctx)).isTrue(); } @Test void testUpdateEntryWithOldTs() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L))).isFalse(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L), ctx)).isFalse(); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java index b1f8063857..94b2a7b389 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java @@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; @@ -26,10 +29,14 @@ import java.util.TreeMap; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class TsRollingArgumentEntryTest { private TsRollingArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + private final long ts = System.currentTimeMillis(); @BeforeEach @@ -51,7 +58,7 @@ public class TsRollingArgumentEntryTest { void testUpdateEntryWhenSingleValueEntryPassed() { SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new DoubleDataEntry("key", 23.0), 123L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(4); assertThat(entry.getTsRecords().get(ts - 10)).isEqualTo(23.0); } @@ -64,7 +71,7 @@ public class TsRollingArgumentEntryTest { values.put(ts - 5, 1.0); newEntry.setTsRecords(values); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(5); assertThat(entry.getTsRecords()).isEqualTo(Map.of( ts - 40, 10.0, @@ -79,7 +86,7 @@ public class TsRollingArgumentEntryTest { void testUpdateEntryWhenValueIsNotNumber() { SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new StringDataEntry("key", "string"), 123L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords().get(ts - 10)).isNaN(); } @@ -93,7 +100,7 @@ public class TsRollingArgumentEntryTest { newEntry.setTsRecords(values); entry = new TsRollingArgumentEntry(3, 30000L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(1); assertThat(entry.getTsRecords()).isEqualTo(Map.of( ts - 5, 0.0 @@ -111,7 +118,7 @@ public class TsRollingArgumentEntryTest { newEntry.setTsRecords(values); entry = new TsRollingArgumentEntry(3, 30000L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(3); assertThat(entry.getTsRecords()).isEqualTo(Map.of( ts - 18, 0.0, diff --git a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java index ebb8305871..1e268e3bcb 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java @@ -230,10 +230,35 @@ public class EdgeMsgConstructorUtilsTest { Assertions.assertEquals(8, getIntValue(assetMergedAttrAD.getBody(), "d")); } + @Test + public void testMergeDownlinkDuplicates_attrBodyHasNoTs_returnOriginalList() { + UUID deviceId = UUID.randomUUID(); + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + var deviceAttrUpdate1 = createEdgeEvent(tenantId, 1, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":1,\"b\":1,\"d\":1}")); + var deviceAttrUpdate2 = createEdgeEvent(tenantId, 2, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":2,\"b\":2,\"c\":2}")); + var deviceAttrUpdate3 = createEdgeEvent(tenantId, 3, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":3,\"d\":3}")); + + List input = List.of(deviceAttrUpdate1, deviceAttrUpdate2, deviceAttrUpdate3); + List merged = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(input); + + Assertions.assertEquals(3, merged.size()); + Assertions.assertEquals(deviceAttrUpdate1, merged.get(0)); + Assertions.assertEquals(deviceAttrUpdate2, merged.get(1)); + Assertions.assertEquals(deviceAttrUpdate3, merged.get(2)); + } + private Integer getIntValue(JsonNode body, String key) { return body.get("kv").get(key) != null ? body.get("kv").get(key).asInt() : null; } + private static JsonNode createAttrBodyWithoutTs(String kvJson) { + return JacksonUtil.toJsonNode("{\"kv\":" + kvJson + "}"); + } + private static JsonNode createAttrBody(long ts, String kvJson) { return JacksonUtil.toJsonNode("{\"ts\":" + ts + ",\"kv\":" + kvJson + "}"); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java index 36cf1639db..7b3102a55c 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java @@ -289,6 +289,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { @Override public String getObjectIdByKeyNameFromProfile(LwM2mClient client, String keyName) { Lwm2mDeviceProfileTransportConfiguration profile = getProfile(client.getRegistration()); + if (profile == null) throw new IllegalArgumentException(keyName + " is not configured in the device profile! Device profile is null"); for (Map.Entry entry : profile.getObserveAttr().getKeyName().entrySet()) { String k = entry.getKey(); String v = entry.getValue(); @@ -347,6 +348,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { PowerMode powerMode = lwM2MClient.getPowerMode(); if (powerMode == null) { Lwm2mDeviceProfileTransportConfiguration deviceProfile = getProfile(lwM2MClient.getRegistration()); + if (deviceProfile == null) return null; powerMode = deviceProfile.getClientLwM2mSettings().getPowerMode(); } return powerMode; @@ -377,8 +379,8 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { } } } else { - log.warn("Device profile not found! The device profile ID is null. Return Lwm2mDeviceProfileTransportConfiguration with default."); - result = new Lwm2mDeviceProfileTransportConfiguration(); + log.warn("Device profile not found! The device profile ID is null. Return Lwm2mDeviceProfileTransportConfiguration with null."); + result = null; } return result; } @@ -413,6 +415,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { OtherConfiguration profileSettings = null; if (powerMode == null && client.getProfileId() != null) { var clientProfile = getProfile(client.getRegistration()); + if (clientProfile == null) return true; profileSettings = clientProfile.getClientLwM2mSettings(); powerMode = profileSettings.getPowerMode(); } @@ -459,8 +462,10 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { OtherConfiguration profileSettings = null; if (powerMode == null && client.getProfileId() != null) { var clientProfile = getProfile(client.getRegistration()); - profileSettings = clientProfile.getClientLwM2mSettings(); - powerMode = profileSettings.getPowerMode(); + if (clientProfile != null) { + profileSettings = clientProfile.getClientLwM2mSettings(); + powerMode = profileSettings.getPowerMode(); + } } if (powerMode == null || PowerMode.DRX.equals(powerMode)) { client.updateLastUplinkTime(); @@ -515,9 +520,11 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { timeout = client.getEdrxCycle(); } else { var clientProfile = getProfile(client.getRegistration()); - OtherConfiguration clientLwM2mSettings = clientProfile.getClientLwM2mSettings(); - if (PowerMode.E_DRX.equals(clientLwM2mSettings.getPowerMode())) { - timeout = clientLwM2mSettings.getEdrxCycle(); + if (clientProfile != null) { + OtherConfiguration clientLwM2mSettings = clientProfile.getClientLwM2mSettings(); + if (PowerMode.E_DRX.equals(clientLwM2mSettings.getPowerMode())) { + timeout = clientLwM2mSettings.getEdrxCycle(); + } } } if (timeout == null || timeout == 0L) { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java index 325f1e2280..cc89fb8fd8 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java @@ -200,7 +200,8 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl attributesToFetch.add(SOFTWARE_URL); } - var clientSettings = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings(); + var clientProfile = clientContext.getProfile(client.getRegistration()); + var clientSettings = clientProfile != null ? clientProfile.getClientLwM2mSettings() : null; if (clientSettings != null) { initFwStrategy(client, clientSettings); initSwStrategy(client, clientSettings); @@ -532,7 +533,8 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl } else { strategy = info.getDeliveryMethod() == FirmwareDeliveryMethod.PULL.code ? LwM2MFirmwareUpdateStrategy.OBJ_5_TEMP_URL : LwM2MFirmwareUpdateStrategy.OBJ_5_BINARY; } - Boolean useObject19ForOtaInfo = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings().getUseObject19ForOtaInfo(); + var clientProfile = clientContext.getProfile(client.getRegistration()); + Boolean useObject19ForOtaInfo = clientProfile != null ? clientProfile.getClientLwM2mSettings().getUseObject19ForOtaInfo() : null; if (useObject19ForOtaInfo != null && useObject19ForOtaInfo){ sendInfoToObject19ForOta(client, FW_INFO_19_INSTANCE_ID, response, otaPackageId); } @@ -558,7 +560,8 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl if (TransportProtos.ResponseStatus.SUCCESS.equals(response.getResponseStatus())) { UUID otaPackageId = new UUID(response.getOtaPackageIdMSB(), response.getOtaPackageIdLSB()); LwM2MSoftwareUpdateStrategy strategy = info.getStrategy(); - Boolean useObject19ForOtaInfo = clientContext.getProfile(client.getRegistration()).getClientLwM2mSettings().getUseObject19ForOtaInfo(); + var clientProfile = clientContext.getProfile(client.getRegistration()); + Boolean useObject19ForOtaInfo = clientProfile != null ? clientProfile.getClientLwM2mSettings().getUseObject19ForOtaInfo() : null; if (useObject19ForOtaInfo != null && useObject19ForOtaInfo){ sendInfoToObject19ForOta(client, SW_INFO_19_INSTANCE_ID, response, otaPackageId); } @@ -647,8 +650,9 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl LwM2MClientSwOtaInfo info = otaInfoStore.getSw(endpoint); if (info == null) { var profile = clientContext.getProfile(client.getRegistration()); - info = new LwM2MClientSwOtaInfo(endpoint, profile.getClientLwM2mSettings().getSwUpdateResource(), - LwM2MSoftwareUpdateStrategy.fromStrategySwByCode(profile.getClientLwM2mSettings().getSwUpdateStrategy())); + OtherConfiguration clientLwM2mSettings = profile == null ? new OtherConfiguration() : profile.getClientLwM2mSettings(); + info = new LwM2MClientSwOtaInfo(endpoint, clientLwM2mSettings.getSwUpdateResource(), + LwM2MSoftwareUpdateStrategy.fromStrategySwByCode(clientLwM2mSettings.getSwUpdateStrategy())); update(info); } return info; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java index 0ea3dbcced..f8ab16502f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java @@ -420,7 +420,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl }); if (!clients.isEmpty()) { var oldProfile = clientContext.getProfile(clients.get(0).getRegistration()); - this.onDeviceProfileUpdate(clients, oldProfile, deviceProfile); + if (oldProfile != null) this.onDeviceProfileUpdate(clients, oldProfile, deviceProfile); } } catch (Exception e) { log.warn("[{}] failed to update profile: {} [{}]", deviceProfile.getId(), e.getMessage(), deviceProfile); @@ -725,8 +725,10 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl private void onDeviceUpdate(LwM2mClient lwM2MClient, Device device, Optional deviceProfileOpt) { var oldProfile = clientContext.getProfile(lwM2MClient.getRegistration()); - deviceProfileOpt.ifPresent(deviceProfile -> this.onDeviceProfileUpdate(Collections.singletonList(lwM2MClient), oldProfile, deviceProfile)); - lwM2MClient.onDeviceUpdate(device, deviceProfileOpt); + if (oldProfile != null) { + deviceProfileOpt.ifPresent(deviceProfile -> this.onDeviceProfileUpdate(Collections.singletonList(lwM2MClient), oldProfile, deviceProfile)); + lwM2MClient.onDeviceUpdate(device, deviceProfileOpt); + } } /** @@ -740,45 +742,12 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl Set paths = updateResource.getPaths(); ResultsAddKeyValueProto results = new ResultsAddKeyValueProto(); var profile = clientContext.getProfile(registration); - List resultAttributes = new ArrayList<>(); - Set attributes = profile.getObserveAttr().getAttribute().stream() - .filter(paths::contains) - .collect(Collectors.toSet()); - if (!attributes.isEmpty()){ - attributes.stream() - .map(attr -> this.getKvToThingsBoard(attr, registration)) - .filter(Objects::nonNull) - .forEach(resultAttributes::add); - } - List resultTelemetries = new ArrayList<>(); - Set telemetries = profile.getObserveAttr().getTelemetry().stream() - .filter(paths::contains) - .collect(Collectors.toSet()); - if (!telemetries.isEmpty()){ - telemetries.stream() - .map(telemetry -> this.getKvToThingsBoard(telemetry, registration)) - .filter(Objects::nonNull) - .forEach(resultTelemetries::add); - } - if (resultAttributes.size() > 0) { - results.setResultAttributes(resultAttributes); - } - if (resultTelemetries.size() > 0) { - results.setResultTelemetries(resultTelemetries); - } - return results; - } - - private ResultsAddKeyValueProto getParametersFromProfile(Registration registration, Set path) { - if (!path.isEmpty()) { - ResultsAddKeyValueProto results = new ResultsAddKeyValueProto(); - var profile = clientContext.getProfile(registration); + if (profile != null) { List resultAttributes = new ArrayList<>(); Set attributes = profile.getObserveAttr().getAttribute().stream() - .map(LwM2MTransportUtil::fromVersionedIdToObjectId) - .filter(path::contains) + .filter(paths::contains) .collect(Collectors.toSet()); - if (!attributes.isEmpty()){ + if (!attributes.isEmpty()) { attributes.stream() .map(attr -> this.getKvToThingsBoard(attr, registration)) .filter(Objects::nonNull) @@ -786,10 +755,9 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl } List resultTelemetries = new ArrayList<>(); Set telemetries = profile.getObserveAttr().getTelemetry().stream() - .map(LwM2MTransportUtil::fromVersionedIdToObjectId) - .filter(path::contains) + .filter(paths::contains) .collect(Collectors.toSet()); - if (!telemetries.isEmpty()){ + if (!telemetries.isEmpty()) { telemetries.stream() .map(telemetry -> this.getKvToThingsBoard(telemetry, registration)) .filter(Objects::nonNull) @@ -801,14 +769,15 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl if (resultTelemetries.size() > 0) { results.setResultTelemetries(resultTelemetries); } - return results; } - return null; + return results; } private TransportProtos.KeyValueProto getKvToThingsBoard(String pathIdVer, Registration registration) { LwM2mClient lwM2MClient = this.clientContext.getClientByEndpoint(registration.getEndpoint()); - Map names = clientContext.getProfile(lwM2MClient.getRegistration()).getObserveAttr().getKeyName(); + var clientProfile = clientContext.getProfile(lwM2MClient.getRegistration()); + if (clientProfile == null) return null; + Map names = clientProfile.getObserveAttr().getKeyName(); if (names != null && names.containsKey(pathIdVer)) { String resourceName = names.get(pathIdVer); if (resourceName != null && !resourceName.isEmpty()) { @@ -899,10 +868,12 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl private void onDeviceProfileUpdate(List clients, Lwm2mDeviceProfileTransportConfiguration oldProfileTransportConfiguration, DeviceProfile deviceProfile) { if (clientContext.profileUpdate(deviceProfile) != null) { var newProfileTransportConfiguration = clientContext.getProfile(clients.get(0).getRegistration()); - ParametersUpdateAnalyzeResult parametersUpdate = getParametersUpdate(oldProfileTransportConfiguration, newProfileTransportConfiguration); - ParametersObserveAnalyzeResult parametersObserve = getParametersObserve(oldProfileTransportConfiguration.getObserveAttr(), newProfileTransportConfiguration.getObserveAttr(), deviceProfile.getId().getId()); - compareAndSetWriteAttributesObservations(clients, parametersUpdate, parametersObserve); - updateValueOta(clients, newProfileTransportConfiguration, oldProfileTransportConfiguration); + if (newProfileTransportConfiguration != null) { + ParametersUpdateAnalyzeResult parametersUpdate = getParametersUpdate(oldProfileTransportConfiguration, newProfileTransportConfiguration); + ParametersObserveAnalyzeResult parametersObserve = getParametersObserve(oldProfileTransportConfiguration.getObserveAttr(), newProfileTransportConfiguration.getObserveAttr(), deviceProfile.getId().getId()); + compareAndSetWriteAttributesObservations(clients, parametersUpdate, parametersObserve); + updateValueOta(clients, newProfileTransportConfiguration, oldProfileTransportConfiguration); + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java index 6f22e95c87..e4f2796d3b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java @@ -85,7 +85,7 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService private String mqttsPemCertFile; @Value("${device.connectivity.coaps.pem_cert_file:}") private String coapsPemCertFile; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Override diff --git a/msa/pom.xml b/msa/pom.xml index f41f7f0fc0..8207996169 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -125,6 +125,92 @@ + + push-lts-docker-amd-arm-images + + + push-lts-docker-amd-arm-images + + + + 4.3.0-latest + false + + + + + org.codehaus.mojo + exec-maven-plugin + + + push-latest-docker-amd-arm-images + ${docker.push-arm-amd-image.phase} + + exec + + + ${docker.skip.latest.tag} + docker + ${project.build.directory} + + buildx + build + -t + ${docker.repo}/${docker.name}:latest + --platform=linux/amd64,linux/arm64 + -o + type=registry + . + + + + + push-version-docker-amd-arm-images + ${docker.push-arm-amd-image.phase} + + exec + + + docker + ${project.build.directory} + + buildx + build + -t + ${docker.repo}/${docker.name}:${project.version} + --platform=linux/amd64,linux/arm64 + -o + type=registry + . + + + + + push-lts-docker-amd-arm-images + ${docker.push-arm-amd-image.phase} + + exec + + + docker + ${project.build.directory} + + buildx + build + -t + ${docker.repo}/${docker.name}:${docker.lts.tag} + --platform=linux/amd64,linux/arm64 + -o + type=registry + . + + + + + + + + diff --git a/ui-ngx/src/app/core/services/calculated-field-form.service.ts b/ui-ngx/src/app/core/services/calculated-field-form.service.ts new file mode 100644 index 0000000000..cc3e495c17 --- /dev/null +++ b/ui-ngx/src/app/core/services/calculated-field-form.service.ts @@ -0,0 +1,112 @@ +/// +/// 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 { DestroyRef, inject, Injectable } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { pairwise, switchMap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { + CalculatedField, + CalculatedFieldConfiguration, + CalculatedFieldEventArguments, + CalculatedFieldType, + OutputStrategyType +} from '@shared/models/calculated-field.models'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { isDefined } from '@core/utils'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { CalculatedFieldsTableEntity } from '@home/components/calculated-fields/calculated-fields-table-config'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Injectable({ providedIn: 'root' }) +export class CalculatedFieldFormService { + private fb = inject(FormBuilder); + private calculatedFieldsService = inject(CalculatedFieldsService); + + buildForm(): FormGroup { + return this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + entityId: [null, Validators.required], + type: [CalculatedFieldType.SIMPLE], + debugSettings: [], + configuration: this.fb.control({} as CalculatedFieldConfiguration), + }); + } + + buildAlarmRuleForm(): FormGroup { + return this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + entityId: [null, Validators.required], + type: [CalculatedFieldType.ALARM], + debugSettings: [], + configuration: this.fb.group({ + type: [CalculatedFieldType.ALARM], + arguments: this.fb.control({}, Validators.required), + propagate: [false], + propagateToOwner: [false], + propagateToTenant: [false], + propagateRelationTypes: [null], + createRules: [null, Validators.required], + clearRule: [null], + }), + }); + } + + setupTypeChange(form: FormGroup, destroyRef: DestroyRef, isEditActive?: () => boolean): void { + form.get('type').valueChanges.pipe( + pairwise(), + takeUntilDestroyed(destroyRef) + ).subscribe(([prevType, nextType]) => { + const shouldCheck = isEditActive ? isEditActive() : true; + if (shouldCheck) { + if (![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(prevType) || + ![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(nextType)) { + form.get('configuration').setValue({} as CalculatedFieldConfiguration, { emitEvent: false }); + } + } + }); + } + + prepareConfig(configuration: CalculatedFieldConfiguration): CalculatedFieldConfiguration { + const config = configuration || ({} as CalculatedFieldConfiguration); + if (config.type !== CalculatedFieldType.ALARM) { + if (isDefined(config?.output) && !config?.output?.strategy) { + config.output.strategy = { type: OutputStrategyType.RULE_CHAIN }; + } + } + return config; + } + + testScript( + calculatedFieldId: string, + formValue: CalculatedField, + testDialogFn: (calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit?: boolean, expression?: string) => Observable, + destroyRef: DestroyRef, + expression?: string, + ): Observable { + if (calculatedFieldId) { + return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true}) + .pipe( + switchMap(event => { + const args = event?.arguments ? JSON.parse(event.arguments) : null; + return testDialogFn(formValue, args, false, expression); + }), + takeUntilDestroyed(destroyRef) + ); + } + return testDialogFn(formValue, null, false, expression); + } +} \ No newline at end of file diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index a57e3480b1..045725271c 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -454,11 +454,11 @@ export class UtilsService { return base64toObj(b64Encoded); } - public applyCssToElement(renderer: Renderer2, element: any, cssClassPrefix: string, css: string): string { + public applyCssToElement(renderer: Renderer2, element: any, cssClassPrefix: string, css: string, addTbDefaultClass: boolean = false): string { const cssParser = new cssjs(); cssParser.testMode = false; const cssClass = `${cssClassPrefix}-${guid()}`; - cssParser.cssPreviewNamespace = cssClass; + cssParser.cssPreviewNamespace = addTbDefaultClass ? 'tb-default .' + cssClass : cssClass; cssParser.createStyleElement(cssClass, css); renderer.addClass(element, cssClass); return cssClass; diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html index 869b707d98..ed936cd60e 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -58,31 +58,15 @@ /> @if (!data.entityId) { -
- - - @if (fieldFormGroup.get('entityId.entityType').value) { - - } -
+ + } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts index a84aa52693..854f7fb1a0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -14,16 +14,15 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FormGroup } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models'; -import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; -import { AliasEntityType, EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ScriptLanguage } from '@shared/models/rule-node.models'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; @@ -39,13 +38,11 @@ import { } from "@shared/models/alarm-rule.models"; import { deepTrim } from "@core/utils"; import { combineLatest, Observable } from "rxjs"; -import { debounceTime, startWith, switchMap } from "rxjs/operators"; -import { EntityTypeSelectComponent } from "@shared/components/entity/entity-type-select.component"; -import { EntityAutocompleteComponent } from "@shared/components/entity/entity-autocomplete.component"; -import { EntityService } from "@core/http/entity.service"; +import { debounceTime, startWith } from "rxjs/operators"; import { RelationTypes } from "@shared/models/relation.models"; import { StringItemsOption } from "@shared/components/string-items-list.component"; import { BaseData } from "@shared/models/base-data"; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; export interface AlarmRuleDialogData { value?: CalculatedField; @@ -67,24 +64,7 @@ export interface AlarmRuleDialogData { }) export class AlarmRuleDialogComponent extends DialogComponent { - fieldFormGroup = this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - type: [CalculatedFieldType.ALARM], - debugSettings: [], - entityId: this.fb.group({ - entityType: this.fb.control(EntityType.DEVICE_PROFILE, Validators.required), - id: [null as null | string, Validators.required], - }), - configuration: this.fb.group({ - arguments: this.fb.control({}, Validators.required), - propagate: [false], - propagateToOwner: [false], - propagateToTenant: [false], - propagateRelationTypes: [null], - createRules: [null, Validators.required], - clearRule: [null], - }), - }); + fieldFormGroup: FormGroup ; additionalDebugActionConfig = this.data.value?.id ? { ...this.data.additionalDebugActionConfig, @@ -105,18 +85,15 @@ export class AlarmRuleDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData, protected dialogRef: MatDialogRef, private calculatedFieldsService: CalculatedFieldsService, - private entityService: EntityService, private destroyRef: DestroyRef, - private fb: FormBuilder) { + private cfFormService: CalculatedFieldFormService) { super(store, router, dialogRef); + this.fieldFormGroup = this.cfFormService.buildAlarmRuleForm(); this.applyDialogData(); this.updateRulesValidators(); @@ -132,7 +109,7 @@ export class AlarmRuleDialogComponent extends DialogComponent { - const calculatedFieldId = this.data.value?.id?.id; - if (calculatedFieldId) { - return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true}) - .pipe( - switchMap(event => { - const args = event?.arguments ? JSON.parse(event.arguments) : null; - return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, args, false); - }), - takeUntilDestroyed(this.destroyRef) - ) - } - return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, null, false); + return this.cfFormService.testScript( + this.data.value?.id?.id, + this.fromGroupValue, + this.data.getTestScriptDialogFn, + this.destroyRef, + expression + ); } private updateRulesValidators(): void { @@ -250,5 +220,4 @@ export class AlarmRuleDialogComponent extends DialogComponent): void { this.entityName = entity.name; } - } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts index 03c6932067..a624a5ae12 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts @@ -196,6 +196,7 @@ export class AlarmRuleFilterConfigComponent implements OnInit, ControlValueAcces cancel() { this.updateAlarmRuleConfigForm(this.alarmRuleFilterConfig); + this.alarmRuleFilterConfigForm.markAsPristine(); if (this.overlayRef) { this.overlayRef.dispose(); } else { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts index 52d91188bc..f7adf5dab0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts @@ -53,6 +53,7 @@ import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alar import { AlarmRuleFilterPredicateNoDataValueComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component"; +import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component'; @NgModule({ declarations: [ @@ -73,7 +74,8 @@ import { AlarmRuleDetailsDialogComponent, AlarmRuleFilterConfigComponent, AlarmRuleTableHeaderComponent, - AlarmRuleFilterPredicateNoDataValueComponent + AlarmRuleFilterPredicateNoDataValueComponent, + AlarmRulesComponent ], imports: [ CommonModule, @@ -83,6 +85,7 @@ import { ], exports: [ AlarmRuleDialogComponent, + AlarmRulesComponent ] }) export class AlarmRuleModule { } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index 46d43f2921..ad718408e4 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -21,7 +21,7 @@ import { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog } from '@angular/material/dialog'; @@ -36,7 +36,7 @@ import { DestroyRef, Renderer2 } from '@angular/core'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; -import { catchError, filter, switchMap, tap } from 'rxjs/operators'; +import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; import { ArgumentEntityType, ArgumentType, @@ -67,6 +67,10 @@ import { CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData } from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; +import { AlarmRulesTabsComponent } from '@home/pages/alarm/alarm-rules-tabs.component'; +import { Router } from '@angular/router'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component'; type AlarmRuleTableEntity = CalculatedField | CalculatedFieldInfo; @@ -75,7 +79,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.openDebugEventsDialog.call(this, calculatedField), + action: (calculatedField: AlarmRuleTableEntity) => this.openDebugEventsDialog.call(this, null, calculatedField), }; alarmRuleFilterConfig: CalculatedFieldsQuery; @@ -93,27 +97,26 @@ export class AlarmRulesTableConfig extends EntityTableConfig { - this.editCalculatedField($event, model); - return true; - }; } this.tableTitle = this.pageMode ? '' : this.translate.instant('alarm-rule.alarm-rules'); - this.detailsPanelEnabled = false; + this.detailsPanelEnabled = this.pageMode; + this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD); this.entityType = EntityType.CALCULATED_FIELD; this.entityTranslations = { type: 'alarm-rule.alarm-rule', typePlural: 'alarm-rule.alarm-rules', list: 'alarm-rule.list', add: 'action.add', + details: 'alarm-rule.details', noEntities: 'alarm-rule.no-found', search: 'action.search', selectedEntities: 'alarm-rule.selected-fields' @@ -121,11 +124,17 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.fetchCalculatedFields(pageLink); this.addEntity = this.getCalculatedAlarmDialog.bind(this); + this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id); + this.saveEntity = (alarmRule) => this.calculatedFieldsService.saveCalculatedField(alarmRule); + this.deleteEntityTitle = (field) => this.translate.instant('alarm-rule.delete-title', {title: field.name}); this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text'); this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count}); this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text'); this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + + this.onEntityAction = action => this.onCFAction(action); + this.addActionDescriptors = [ { name: this.translate.instant('alarm-rule.create'), @@ -177,7 +186,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig true, - onAction: ($event, entity) => this.openDebugEventsDialog($event, entity), + onAction: ($event, entity) => + this.pageMode ? this.openDebugTab($event, entity) : this.openDebugEventsDialog($event, entity), }, { name: '', @@ -186,14 +196,18 @@ export class AlarmRulesTableConfig extends EntityTableConfig true, iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), - }, - { - name: this.translate.instant('action.edit'), - icon: 'edit', - isEnabled: () => true, - onAction: ($event, entity) => this.editCalculatedField($event, entity), } ); + if (!this.pageMode) { + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editCalculatedField($event, entity), + } + ) + } } fetchCalculatedFields(pageLink: PageLink): Observable> { @@ -288,13 +302,29 @@ export class AlarmRulesTableConfig extends EntityTableConfig false + debugActionDisabled: true } }) .afterClosed() .subscribe(); } + private openDebugTab($event: Event, calculatedField: AlarmRuleTableEntity) { + const table = this.getTable(); + if (!table.isDetailsOpen) { + table.toggleEntityDetails($event, calculatedField); + if (table.entityDetailsPanel.matTabGroup._tabs.length > 1) { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + } else { + table.entityDetailsPanel.matTabGroup._tabs.changes.pipe( + first() + ).subscribe(() => { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + }) + } + } + } + private exportAlarmRule($event: Event, calculatedField: AlarmRuleTableEntity): void { $event?.stopPropagation(); this.importExportService.exportCalculatedField(calculatedField.id.id); @@ -348,7 +378,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.updateData()); } - private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, expression: string, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable { + private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { if (calculatedField.type === CalculatedFieldType.ALARM) { const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { const type = calculatedField.configuration.arguments[key].refEntityKey.type; @@ -384,4 +414,22 @@ export class AlarmRulesTableConfig extends EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openCalculatedField(action.event, action.entity); + return true; + case 'export': + this.exportAlarmRule(action.event, action.entity); + return true; + } + return false; + } } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts index 7f4c0b4ee7..aaddf94aac 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts @@ -36,7 +36,7 @@ import { EntityDebugSettingsService } from '@home/components/entity/debug/entity import { DatePipe } from '@angular/common'; import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; import { UtilsService } from "@core/services/utils.service"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; @Component({ selector: 'tb-alarm-rules-table', @@ -70,6 +70,7 @@ export class AlarmRulesTableComponent { private utilsService: UtilsService, private destroyRef: DestroyRef, private route: ActivatedRoute, + private router: Router ) { this.pageMode = !!this.route.snapshot.data.isPage; effect(() => { @@ -88,6 +89,7 @@ export class AlarmRulesTableComponent { this.importExportService, this.entityDebugSettingsService, this.utilsService, + this.router, this.pageMode, ); this.cd.markForCheck(); diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html new file mode 100644 index 0000000000..64f4ab652f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html @@ -0,0 +1,159 @@ + +
+ + + +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'entity-field.title' | translate }} + + @if (entityForm.get('name').errors && entityForm.get('name').touched) { + + @if (entityForm.get('name').hasError('required')) { + {{ 'common.hint.title-required' | translate }} + } @else if (entityForm.get('name').hasError('pattern')) { + {{ 'common.hint.title-pattern' | translate }} + } @else if (entityForm.get('name').hasError('maxlength')) { + {{ 'common.hint.title-max-length' | translate }} + } + + } + + + +
+ + +
+ +
+
{{ 'calculated-fields.arguments' | translate }}
+ +
+
+
{{ 'alarm-rule.create-conditions' | translate }}
+
+ + +
+
+
+
{{ 'alarm-rule.clear-condition' | translate }}
+
+
+ + +
+ +
+
+ alarm-rule.no-clear-alarm-rule +
+
+ +
+
+
+ + + {{ 'alarm-rule.advanced-settings' | translate }} + + +
+
+ + {{ 'alarm-rule.propagate-alarm' | translate }} + +
+ @if (configFormGroup.get('propagate').value) { + + + } +
+
+ + {{ 'alarm-rule.propagate-alarm-to-owner' | translate }} + +
+
+ + {{ 'alarm-rule.propagate-alarm-to-tenant' | translate }} + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts new file mode 100644 index 0000000000..fb92f6c4fd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts @@ -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. +/// + +import { ChangeDetectorRef, Component, DestroyRef, inject, Inject, Input } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '@home/components/entity/entity.component'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { + CalculatedFieldArgument, + CalculatedFieldConfiguration, + CalculatedFieldInfo, + calculatedFieldsEntityTypeList, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { BaseData } from '@shared/models/base-data'; +import { Observable } from 'rxjs'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { + CalculatedFieldsTableConfig, + CalculatedFieldsTableEntity +} from '@home/components/calculated-fields/calculated-fields-table-config'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { StringItemsOption } from '@shared/components/string-items-list.component'; +import { RelationTypes } from '@shared/models/relation.models'; +import { AlarmRule, AlarmRuleConditionType, AlarmRuleExpressionType } from '@shared/models/alarm-rule.models'; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; + +@Component({ + selector: 'tb-alarm-rules', + templateUrl: './alarm-rules.component.html', + styleUrls: [] +}) +export class AlarmRulesComponent extends EntityComponent { + + @Input() + standalone = false; + + @Input() + entityName: string; + + readonly ownerId = new TenantId(getCurrentAuthUser(this.store).tenantId); + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + readonly EntityType = EntityType; + readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList; + readonly CalculatedFieldType = CalculatedFieldType; + + private cfFormService = inject(CalculatedFieldFormService); + private destroyRef = inject(DestroyRef); + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: CalculatedFieldInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig, + protected fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + additionalDebugActionConfig = { + ...this.entitiesTableConfig.additionalDebugActionConfig, + action: () => this.entitiesTableConfig.additionalDebugActionConfig.action( + { id: this.entity.id, ...this.entityFormValue() }, false, + (expression) => { + if (expression) { + this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression}); + this.entityForm.get('configuration').markAsDirty(); + } + }), + }; + + get entityId(): EntityId { + return this.entityForm.get('entityId').value; + } + + get entitiesTableConfig(): CalculatedFieldsTableConfig { + return this.entitiesTableConfigValue; + } + + changeEntity(entity: BaseData): void { + this.entityName = entity?.name; + } + + buildForm(_entity?: CalculatedFieldInfo): FormGroup { + return inject(CalculatedFieldFormService).buildAlarmRuleForm(); + } + + updateForm(entity: CalculatedFieldInfo) { + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {}; + setTimeout(() => { + this.entityForm.patchValue({ configuration, debugSettings, entityId, ...value }, {emitEvent: false}); + }); + if (!entityId) { + this.entityForm.get('configuration').disable({emitEvent: false}); + } + } + + onTestScript(expression?: string): Observable { + return this.cfFormService.testScript( + this.entity?.id?.id, + this.entityFormValue(), + this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig), + this.destroyRef, + expression + ); + } + + updateFormState() { + if (this.entityForm) { + if (this.isEditValue) { + this.entityForm.enable({emitEvent: false}); + this.entityForm.get('entityId').disable({emitEvent: false}); + } else { + this.entityForm.disable({emitEvent: false}); + } + } + } + + get arguments(): Record { + return this.entityForm.get('configuration.arguments').value; + } + + get predefinedTypeValues(): StringItemsOption[] { + return RelationTypes.map(type => ({ + name: type, + value: type + })); + } + + get configFormGroup(): FormGroup { + return this.entityForm.get('configuration') as FormGroup; + } + + public removeClearAlarmRule() { + this.configFormGroup.patchValue({clearRule: null}); + this.entityForm.markAsDirty(); + } + + public addClearAlarmRule() { + const clearAlarmRule: AlarmRule = { + condition: { + type: AlarmRuleConditionType.SIMPLE, + expression: { + type: AlarmRuleExpressionType.SIMPLE + } + } + }; + this.configFormGroup.patchValue({clearRule: clearAlarmRule}); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html index 22fc01f9de..99ee0f9634 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
{{ 'alarm-rule.condition' | translate }}
+ + +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'entity-field.title' | translate }} + + @if (entityForm.get('name').errors && entityForm.get('name').touched) { + + @if (entityForm.get('name').hasError('required')) { + {{ 'common.hint.title-required' | translate }} + } @else if (entityForm.get('name').hasError('pattern')) { + {{ 'common.hint.title-pattern' | translate }} + } @else if (entityForm.get('name').hasError('maxlength')) { + {{ 'common.hint.title-max-length' | translate }} + } + + } + + + +
+ + + + {{ 'common.type' | translate }} + + @for (type of fieldTypes; track type) { + {{ CalculatedFieldTypeTranslations.get(type).name | translate }} + } + + @if (CalculatedFieldTypeTranslations.get(entityForm.get('type').value).hint) { + {{ CalculatedFieldTypeTranslations.get(entityForm.get('type').value).hint | translate }} + } + +
+ @switch (entityForm.get('type').value) { + @case (CalculatedFieldType.GEOFENCING) { + + } + @case (CalculatedFieldType.PROPAGATION) { + + } + @case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) { + + } + @case (CalculatedFieldType.ENTITY_AGGREGATION) { + + } + @default { + + } + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss new file mode 100644 index 0000000000..5bcd2552fc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss @@ -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. + */ +:host ::ng-deep { + .tbel-script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + min-width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; + } + + .tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts, &-latestTs { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts new file mode 100644 index 0000000000..65595fe9b8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts @@ -0,0 +1,145 @@ +/// +/// 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, inject, Inject, Input } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { + CalculatedFieldConfiguration, + CalculatedFieldInfo, + calculatedFieldsEntityTypeList, + CalculatedFieldType, + calculatedFieldTypes, + CalculatedFieldTypeTranslations +} from '@shared/models/calculated-field.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { BaseData } from '@shared/models/base-data'; +import { Observable } from 'rxjs'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { + CalculatedFieldsTableConfig, + CalculatedFieldsTableEntity +} from '@home/components/calculated-fields/calculated-fields-table-config'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; + +@Component({ + selector: 'tb-calculated-field', + templateUrl: './calculated-field.component.html', + styleUrls: ['./calculated-field.component.scss'] +}) +export class CalculatedFieldComponent extends EntityComponent { + + @Input() + standalone = false; + + @Input() + entityName: string; + + disabledConfiguration = false; + + readonly ownerId = new TenantId(getCurrentAuthUser(this.store).tenantId); + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + readonly EntityType = EntityType; + readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList; + readonly CalculatedFieldType = CalculatedFieldType; + readonly fieldTypes = calculatedFieldTypes; + readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; + + private cfFormService = inject(CalculatedFieldFormService); + private destroyRef = inject(DestroyRef); + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: CalculatedFieldInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig, + protected fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + additionalDebugActionConfig = { + ...this.entitiesTableConfig.additionalDebugActionConfig, + action: () => this.entitiesTableConfig.additionalDebugActionConfig.action( + { id: this.entity.id, ...this.entityFormValue() }, false, + (expression) => { + if (expression) { + this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression}); + this.entityForm.get('configuration').markAsDirty(); + } + }), + }; + + get entityId(): EntityId { + return this.entityForm.get('entityId').value; + } + + get entitiesTableConfig(): CalculatedFieldsTableConfig { + return this.entitiesTableConfigValue; + } + + changeEntity(entity: BaseData): void { + this.entityName = entity?.name; + } + + buildForm(_entity?: CalculatedFieldInfo): FormGroup { + const form = inject(CalculatedFieldFormService).buildForm(); + inject(CalculatedFieldFormService).setupTypeChange(form, inject(DestroyRef), () => this.isEditValue); + return form; + } + + updateForm(entity: CalculatedFieldInfo) { + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {}; + const preparedConfig = this.cfFormService.prepareConfig(configuration); + this.entityForm.patchValue({ type }, {emitEvent: false, onlySelf: true}); + setTimeout(() => { + this.entityForm.patchValue({ configuration: preparedConfig, debugSettings, entityId, ...value }, {emitEvent: false}); + }); + } + + onTestScript(expression?: string): Observable { + return this.cfFormService.testScript( + this.entity?.id?.id, + this.entityFormValue(), + this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig), + this.destroyRef, + expression + ); + } + + updateFormState() { + if (this.entityForm) { + if (this.isEditValue) { + this.entityForm.enable({emitEvent: false}); + this.entityForm.get('entityId').disable({emitEvent: false}); + } else { + this.entityForm.disable({emitEvent: false}); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts index e3f5ac9cc2..2bcfcd7a1b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -50,6 +50,7 @@ import { import { CalculatedFieldsFilterConfigComponent } from '@home/components/calculated-fields/table-header/calculated-fields-filter-config.component'; +import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component'; @NgModule({ declarations: [ @@ -57,7 +58,8 @@ import { CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestArgumentsComponent, CalculatedFieldsHeaderComponent, - CalculatedFieldsFilterConfigComponent + CalculatedFieldsFilterConfigComponent, + CalculatedFieldComponent, ], imports: [ CommonModule, @@ -72,6 +74,7 @@ import { exports: [ CalculatedFieldDialogComponent, CalculatedFieldScriptTestDialogComponent, + CalculatedFieldComponent, ] }) export class CalculatedFieldsModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 8385b5cb45..e09949d2c6 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -20,7 +20,7 @@ import { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @@ -35,7 +35,7 @@ import { DestroyRef, Renderer2 } from '@angular/core'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; -import { catchError, filter, switchMap, tap } from 'rxjs/operators'; +import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; import { ArgumentEntityType, ArgumentType, @@ -46,6 +46,7 @@ import { CalculatedFieldsQuery, CalculatedFieldType, CalculatedFieldTypeTranslations, + debugCfActionEnabled, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, PropagationWithExpression, @@ -62,20 +63,26 @@ import { EntityDebugSettingsService } from '@home/components/entity/debug/entity import { DatePipe } from '@angular/common'; import { UtilsService } from "@core/services/utils.service"; import { ActionNotificationShow } from "@core/notification/notification.actions"; -import { CalculatedFieldEventBody, DebugEventType, Event as DebugEvent, EventType } from '@shared/models/event.models'; +import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-dialog.component'; import { CalculatedFieldsHeaderComponent } from '@home/components/calculated-fields/table-header/calculated-fields-header.component'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component'; +import { Router } from '@angular/router'; +import { CalculatedFieldsTabsComponent } from '@home/pages/calculated-fields/calculated-fields-tabs.component'; -type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo; +export type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo; export class CalculatedFieldsTableConfig extends EntityTableConfig { readonly tenantId = getCurrentAuthUser(this.store).tenantId; additionalDebugActionConfig = { title: this.translate.instant('action.see-debug-events'), - action: (calculatedField: CalculatedFieldsTableEntity) => this.openDebugEventsDialog.call(this, null, calculatedField), + action: (calculatedField: CalculatedFieldsTableEntity, + openCalculatedFieldEdit = true, + afterCloseCallback?: (expression: string) => void) => this.openDebugEventsDialog.call(this, null, calculatedField, openCalculatedFieldEdit, afterCloseCallback), }; calculatedFieldFilterConfig: CalculatedFieldsQuery; @@ -93,30 +100,35 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { - this.editCalculatedField($event, entity); - this.rowPointer = true; - return true; - }; + this.entityComponent = CalculatedFieldComponent; + this.entityTabsComponent = CalculatedFieldsTabsComponent; + this.rowPointer = true; } - this.tableTitle = this.pageMode ? '' : this.translate.instant('entity.type-calculated-fields'); - this.detailsPanelEnabled = false; + this.tableTitle = this.translate.instant('entity.type-calculated-fields'); + this.detailsPanelEnabled = this.pageMode; this.entityType = EntityType.CALCULATED_FIELD; this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD); + this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD); this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink); this.addEntity = this.getCalculatedFieldDialog.bind(this); + this.saveEntity = (cf) => this.calculatedFieldsService.saveCalculatedField(cf); + this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id); this.deleteEntityTitle = (field) => this.translate.instant('calculated-fields.delete-title', {title: field.name}); this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text'); this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count}); this.deleteEntitiesContent = () => this.translate.instant('calculated-fields.delete-multiple-text'); this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + + this.onEntityAction = action => this.onCFAction(action); + this.addActionDescriptors = [ { name: this.translate.instant('calculated-fields.create'), @@ -163,7 +175,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig true, - onAction: ($event, entity) => this.openDebugEventsDialog($event, entity), + onAction: ($event, entity) => + this.pageMode ? this.openDebugTab($event, entity) : this.openDebugEventsDialog($event, entity), }, { name: '', @@ -172,14 +185,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig true, iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), - }, - { + } + ); + if (!this.pageMode) { + this.cellActionDescriptors.push({ name: this.translate.instant('action.edit'), icon: 'edit', isEnabled: () => true, onAction: ($event, entity) => this.editCalculatedField($event, entity), - } - ); + }) + } } fetchCalculatedFields(pageLink: PageLink): Observable> { @@ -188,7 +203,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig 1) { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + } else { + table.entityDetailsPanel.matTabGroup._tabs.changes.pipe( + first() + ).subscribe(() => { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + }) + } + } + } + private editCalculatedField($event: Event, calculatedField: CalculatedFieldsTableEntity, isDirty = false): void { $event?.stopPropagation(); this.getCalculatedFieldDialog(calculatedField, 'action.apply', isDirty) @@ -222,7 +253,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { + private getCalculatedFieldDialog(value?: CalculatedFieldsTableEntity, buttonTitle = 'action.add', isDirty = false, disabledSelectType = false): Observable { const entityId = this.entityId || value?.entityId; const entityName = this.entityName || (value as CalculatedFieldInfo)?.entityName; return this.dialog.open(CalculatedFieldDialogComponent, { @@ -234,10 +265,11 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig void ): void { $event?.stopPropagation(); - const debugActionEnabledFn = (event: DebugEvent) => { - return (calculatedField.type === CalculatedFieldType.SCRIPT || - (calculatedField.type === CalculatedFieldType.PROPAGATION && - calculatedField.configuration.applyExpressionToResolvedArguments) - ) && !!(event as DebugEvent).body.arguments; - }; const onDebugEventSelected = (event: CalculatedFieldEventBody, dialogRef: MatDialogRef) => { - this.getTestScriptDialog(calculatedField, JSON.parse(event.arguments)) + this.getTestScriptDialog(calculatedField, JSON.parse(event.arguments), openCalculatedFieldEdit) .subscribe(expression => dialogRef.close(expression)); }; @@ -270,11 +296,15 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { + if (afterCloseCallback) { + afterCloseCallback(value) + } + }); } private exportCalculatedField($event: Event, calculatedField: CalculatedFieldsTableEntity): void { @@ -315,7 +345,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), + switchMap(calculatedField => this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true, true)), filter(Boolean), switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), filter(Boolean), @@ -354,7 +384,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.updateData()); } - private getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { + getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { if ( calculatedField.type === CalculatedFieldType.SCRIPT || calculatedField.type === CalculatedFieldType.RELATED_ENTITIES_AGGREGATION || @@ -394,4 +424,22 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openCalculatedField(action.event, action.entity); + return true; + case 'export': + this.exportCalculatedField(action.event, action.entity); + return true; + } + return false; + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts index 2a0c29e31a..7687277113 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts @@ -36,7 +36,7 @@ import { ImportExportService } from '@shared/import-export/import-export.service import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; import { UtilsService } from "@core/services/utils.service"; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; @Component({ selector: 'tb-calculated-fields-table', @@ -70,6 +70,7 @@ export class CalculatedFieldsTableComponent { private utilsService: UtilsService, private destroyRef: DestroyRef, private route: ActivatedRoute, + private router: Router ) { this.pageMode = !!this.route.snapshot.data.isPage; effect(() => { @@ -88,6 +89,7 @@ export class CalculatedFieldsTableComponent { this.importExportService, this.entityDebugSettingsService, this.utilsService, + this.router, this.pageMode, ); this.cd.markForCheck(); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index 38855ec79e..51d14c9478 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -259,7 +259,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI }; } if (!onInit) { - this.argumentFormGroup.get('refEntityKey').get('key').setValue(''); + this.argumentFormGroup.get('refEntityKey').get('key').setValue('', {emitEvents: !this.watchKeyChange}); + if (this.watchKeyChange && this.argumentFormGroup.get('argumentName').pristine) { + this.argumentFormGroup.get('argumentName').markAsUntouched({emitEvent: false}); + this.argumentFormGroup.get('argumentName').setValue('', {emitEvent: false}); + } } else if (this.predefinedEntityFilter) { entityFilter = this.predefinedEntityFilter; } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts index 9a8395a557..a05326699d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts @@ -154,7 +154,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces validate(): ValidationErrors | null { this.updateErrorText(); - return this.errorText ? { argumentsFormArray: false } : null; + return this.errorText || !this.argumentsFormArray.controls.length ? { argumentsFormArray: false } : null; } setDisabledState(isDisabled: boolean): void { @@ -239,7 +239,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces } writeValue(argumentsObj: Record): void { - this.argumentsFormArray.clear(); + this.argumentsFormArray.clear({emitEvent: false}); this.populateArgumentsFormArray(argumentsObj); this.updateEntityNameMap(this.argumentsFormArray.value); } @@ -249,7 +249,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces } protected changeIsScriptMode(): void { - this.argumentsFormArray.updateValueAndValidity(); + this.argumentsFormArray.updateValueAndValidity({emitEvent: !this.disable}); } protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean { @@ -264,7 +264,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces }; this.argumentsFormArray.push(this.fb.control(value), { emitEvent: false }); }); - this.argumentsFormArray.updateValueAndValidity(); + this.updateDataSource(this.argumentsFormArray.value); } private updateEntityNameMap(values: CalculatedFieldArgumentValue[]): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 3d669b02c6..293dacdca6 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -56,29 +56,15 @@ />
@if (!data.entityId) { -
- - - @if (fieldFormGroup.get('entityId.entityType').value) { - - } -
+ + } {{ 'common.type' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 4d2df6e416..ee38fb2a2d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -14,11 +14,10 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { @@ -28,21 +27,18 @@ import { CalculatedFieldTestScriptFn, CalculatedFieldType, calculatedFieldTypes, - CalculatedFieldTypeTranslations, - OutputStrategyType + CalculatedFieldTypeTranslations } from '@shared/models/calculated-field.models'; -import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; -import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; -import { pairwise, switchMap } from 'rxjs/operators'; +import { EntityType } from '@shared/models/entity-type.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { Observable } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; -import { deepTrim, isDefined } from '@core/utils'; -import { EntityTypeSelectComponent } from '@shared/components/entity/entity-type-select.component'; -import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; +import { deepTrim } from '@core/utils'; import { BaseData } from '@shared/models/base-data'; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; +import { FormGroup } from '@angular/forms'; export interface CalculatedFieldDialogData { value?: CalculatedField; @@ -54,6 +50,7 @@ export interface CalculatedFieldDialogData { additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; getTestScriptDialogFn: CalculatedFieldTestScriptFn; isDirty?: boolean; + disabledSelectType?: boolean; } @Component({ @@ -64,16 +61,7 @@ export interface CalculatedFieldDialogData { }) export class CalculatedFieldDialogComponent extends DialogComponent { - fieldFormGroup = this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - entityId: this.fb.group({ - entityType: this.fb.control(EntityType.DEVICE_PROFILE, Validators.required), - id: [null as null | string, Validators.required], - }), - type: [CalculatedFieldType.SIMPLE], - debugSettings: [], - configuration: this.fb.control({} as CalculatedFieldConfiguration), - }); + fieldFormGroup: FormGroup; additionalDebugActionConfig = this.data.value?.id ? { ...this.data.additionalDebugActionConfig, @@ -91,18 +79,16 @@ export class CalculatedFieldDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData, protected dialogRef: MatDialogRef, private calculatedFieldsService: CalculatedFieldsService, private destroyRef: DestroyRef, - private fb: FormBuilder) { + private cfFormService: CalculatedFieldFormService) { super(store, router, dialogRef); - this.observeType(); + this.fieldFormGroup = this.cfFormService.buildForm(); + this.cfFormService.setupTypeChange(this.fieldFormGroup, this.destroyRef); this.applyDialogData(); if (this.data.isDirty) { @@ -110,7 +96,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent { this.disabledConfiguration = !entityId; @@ -121,10 +107,14 @@ export class CalculatedFieldDialogComponent extends DialogComponent { - const calculatedFieldId = this.data.value?.id?.id; - if (calculatedFieldId) { - return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true}) - .pipe( - switchMap(event => { - const args = event?.arguments ? JSON.parse(event.arguments) : null; - return this.data.getTestScriptDialogFn(this.fromGroupValue, args, false, expression); - }), - takeUntilDestroyed(this.destroyRef) - ) - } - return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false, expression); + return this.cfFormService.testScript( + this.data.value?.id?.id, + this.fromGroupValue, + this.data.getTestScriptDialogFn, + this.destroyRef, + expression + ); } changeEntity(entity: BaseData): void { @@ -172,28 +155,12 @@ export class CalculatedFieldDialogComponent extends DialogComponent this.fieldFormGroup.get('type').updateValueAndValidity({onlySelf: true})); if (!this.data.entityId) { this.fieldFormGroup.get('configuration').disable({emitEvent: false}); this.disabledConfiguration = true; } } - - private observeType(): void { - this.fieldFormGroup.get('type').valueChanges.pipe( - pairwise(), - takeUntilDestroyed(this.destroyRef) - ).subscribe(([prevType, nextType]) => { - if (![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(prevType) || - ![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(nextType)) { - this.fieldFormGroup.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false}); - } - }); - } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts index e3655f5370..6f281b8e25 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts @@ -182,10 +182,12 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor interval: {...value.interval, allowOffsetSec: isDefinedAndNotNull(value?.interval?.offsetSec)} } this.entityAggregationConfiguration.patchValue(data, {emitEvent: false}); - this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); - this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); - this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); - this.checkProduceIntermediate(); + if (this.entityAggregationConfiguration.enabled) { + this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); + this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); + this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); + this.checkProduceIntermediate(); + } this.updatedOffsetHint(); setTimeout(() => { this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html index 9f4e331cc1..e09d5b0f4e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -95,7 +95,9 @@
- {{ 'calculated-fields.entity-zone-relationship' | translate }} + + {{ 'calculated-fields.entity-zone-relationship' | translate }} +
@@ -221,7 +223,7 @@
-
+
{{ 'calculated-fields.create-relation-with-matched-zones' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts index 228d08fd90..e102033f18 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts @@ -214,7 +214,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro } writeValue(zonesObj: Record): void { - this.zoneGroupsFormArray.clear(); + this.zoneGroupsFormArray.clear({emitEvent: false}); this.populateZonesFormArray(zonesObj); this.updateEntityNameMap(this.zoneGroupsFormArray.value); } @@ -231,7 +231,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro }; this.zoneGroupsFormArray.push(this.fb.control(value), { emitEvent: false }); }); - this.zoneGroupsFormArray.updateValueAndValidity(); + this.updateDataSource(this.zoneGroupsFormArray.value); } private updateEntityNameMap(values: CalculatedFieldGeofencingValue[]): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts index 3406922b9e..9df60b6094 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts @@ -124,7 +124,9 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V writeValue(config: CalculatedFieldGeofencingConfiguration): void { this.geofencingConfiguration.patchValue(config, {emitEvent: false}); this.checkRelatedEntity(this.geofencingConfiguration.get('zoneGroups').value); - this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value); + if (this.geofencingConfiguration.enabled) { + this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value); + } } registerOnChange(fn: (config: CalculatedFieldGeofencingConfiguration) => void): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html index f86c15ba73..178fedbecd 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html @@ -170,7 +170,7 @@ [highlightRules]="highlightRules" [editorCompleter]="editorCompleter" [helpPopupStyle]="{ width: '1200px' }" - helpId="calculated-field/expression_fn"> + helpId="calculated-field/map_expression_fn">
{{ 'api-usage.tbel' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts index 3d5a53ea4e..811f7b87fa 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts @@ -74,7 +74,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { entityFilter: EntityFilter; - readonly AggFunctions = Object.values(AggFunction) as AggFunction[]; + AggFunctions = Object.values(AggFunction) as AggFunction[]; readonly AggFunctionTranslations = AggFunctionTranslations; readonly ScriptLanguage = ScriptLanguage; readonly AggInputType = AggInputType; @@ -103,6 +103,10 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { this.validateInputKey(); this.functionArgs = ['ctx', ...this.arguments]; + + if (this.simpleMode) { + this.AggFunctions = this.AggFunctions.filter(aggFunc => aggFunc !== AggFunction.COUNT_UNIQUE); + } } saveMetric(): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts index 21b21d07ac..531f8d1014 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts @@ -218,7 +218,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu } writeValue(metrics: Record): void { - this.metricsFormArray.clear(); + this.metricsFormArray.clear({emitEvent: false}); this.populateZonesFormArray(metrics); } @@ -230,7 +230,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu }; this.metricsFormArray.push(this.fb.control(value), { emitEvent: false }); }); - this.metricsFormArray.updateValueAndValidity(); + this.updateDataSource(this.metricsFormArray.value); } private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts index f1e8646a6c..63e3faf131 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts @@ -148,7 +148,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val for (const propName of Object.keys(changes)) { const change = changes[propName]; if (change.currentValue !== change.previousValue) { - if (propName === 'simpleMode') { + if (propName === 'simpleMode' && !this.disabled) { this.updatedFormWithMode(); if (!change.firstChange) { this.outputForm.updateValueAndValidity(); @@ -167,7 +167,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val if (value.type === OutputType.Timeseries && value.strategy?.type === OutputStrategyType.IMMEDIATE && value.strategy?.ttl) { this.outputForm.get('strategy.useCustomTtl').setValue(true, {emitEvent: false}); } - this.outputForm.get('type').updateValueAndValidity({onlySelf: true}); + this.outputForm.get('type').updateValueAndValidity({onlySelf: true, emitEvent: false}); } registerOnChange(fn: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void): void { @@ -253,7 +253,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val private updateTimeSeriesTtl(value: boolean) { if (value) { - this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: true}); + this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: false}); } else { this.outputForm.get('strategy.useCustomTtl').disable({emitEvent: false}); this.outputForm.get('strategy.ttl').disable({emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts index 1c493148b3..33acbcf86d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts @@ -131,9 +131,11 @@ export class PropagationConfigurationComponent implements ControlValueAccessor, writeValue(value: PropagationWithExpression): void { value.expression = value.expression ?? calculatedFieldDefaultScript; this.propagateConfiguration.patchValue(value, {emitEvent: false}); - this.updatedFormWithScript(); + if (!this.disabled) { + this.updatedFormWithScript(); + } setTimeout(() => { - this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true, emitEvent: false}); }); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts index e6ff1d1bad..ec07fcae03 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -151,7 +151,6 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid formValue.expressionSIMPLE = formValue.expression; } this.simpleConfiguration.patchValue(formValue, {emitEvent: false}); - this.updatedFormWithScript(); setTimeout(() => { this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); }); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts index 83ea05af4c..ffe986d434 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts @@ -204,6 +204,7 @@ export class CalculatedFieldsFilterConfigComponent implements OnInit, ControlVal cancel() { this.updateCfConfigForm(this.cfFilterConfig); + this.cfFilterForm.markAsPristine(); if (this.overlayRef) { this.overlayRef.dispose(); } else { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index d0c3325c72..090822a146 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -548,6 +548,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC const cssParser = new cssjs(); cssParser.testMode = false; this.dashboardPageClass = 'tb-dashboard-page-css-' + guid(); + this.dashboardCtx.dashboardCssClass = this.dashboardPageClass; cssParser.cssPreviewNamespace = 'tb-default .' + this.dashboardPageClass; cssParser.createStyleElement(this.dashboardPageClass, cssString); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts index a0fa516374..fec05ff2dd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts @@ -51,6 +51,7 @@ export interface DashboardContext { stateChanged: Observable; stateId: Observable; runChangeDetection: () => void; + dashboardCssClass?: string; } export interface IDashboardController { diff --git a/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts b/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts index d6a81883d2..531323bf5c 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts @@ -196,6 +196,7 @@ export class DeviceInfoFilterComponent implements OnInit, ControlValueAccessor { cancel() { this.updateDeviceInfoFilterForm(this.deviceInfoFilter); + this.deviceInfoFilterForm.markAsPristine(); if (this.overlayRef) { this.overlayRef.dispose(); } else { diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts index cb982b9650..76def20a24 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts @@ -22,7 +22,9 @@ import { TranslateService } from '@ngx-translate/core'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { EntityDebugSettingPanelConfig } from '@home/components/entity/debug/entity-debug-settings.model'; -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class EntityDebugSettingsService { constructor( diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts index 3d297f0317..066ef268e5 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts @@ -16,7 +16,7 @@ import { BaseData, HasId } from '@shared/models/base-data'; import { PageComponent } from '@shared/components/page.component'; -import { AfterViewInit, Directive, Input, OnInit, QueryList, ViewChildren } from '@angular/core'; +import { AfterViewInit, Directive, inject, Input, OnInit, QueryList, ViewChildren } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; @@ -100,9 +100,11 @@ export abstract class EntityTabsComponent, entityTabsChanged = this.entityTabsSubject.asObservable(); - protected constructor(protected store: Store) { - super(store); - this.authUser = getCurrentAuthUser(store); + protected store: Store = inject(Store); + + protected constructor(...args: unknown[]) { + super(); + this.authUser = getCurrentAuthUser(this.store); } ngOnInit() { diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index 03fc641735..accc3d7060 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -22,7 +22,14 @@ import { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { DebugEventType, Event, EventBody, EventType, FilterEventBody } from '@shared/models/event.models'; +import { + DebugEventType, + Event as DebugEvent, + Event, + EventBody, + EventType, + FilterEventBody +} from '@shared/models/event.models'; import { TimePageLink } from '@shared/models/page/page-link'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; @@ -95,7 +102,8 @@ export class EventTableConfig extends EntityTableConfig { private store: Store, public testButtonLabel?: string, private debugEventSelected?: EventEmitter, - public hideClearEventAction = false) { + public hideClearEventAction = false, + public disableDebugEventAction = false) { super(); this.loadDataOnInit = false; this.tableTitle = ''; @@ -453,7 +461,7 @@ export class EventTableConfig extends EntityTableConfig { this.cellActionDescriptors.push({ name: this.translate.instant('rulenode.test-with-this-message', {test: this.translate.instant(this.testButtonLabel)}), icon: 'bug_report', - isEnabled: (entity) => entity.body.type === 'IN' || entity.body.error !== undefined, + isEnabled: (entity) => (entity.body.type === 'IN' || entity.body.error !== undefined) && !this.disableDebugEventAction, onAction: ($event, entity) => { this.debugEventSelected.next(entity.body); } @@ -464,7 +472,7 @@ export class EventTableConfig extends EntityTableConfig { this.cellActionDescriptors.push({ name: this.translate.instant('calculated-fields.test-with-this-message'), icon: 'bug_report', - isEnabled: () => true, + isEnabled: (event) => !this.disableDebugEventAction && !!(event as DebugEvent).body.arguments, onAction: (_, entity) => this.debugEventSelected.next(entity.body) }); break; diff --git a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts index 53cb8e1b96..7011c23449 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts @@ -17,10 +17,12 @@ import { AfterViewInit, ChangeDetectorRef, - Component, EventEmitter, + Component, + EventEmitter, Input, OnDestroy, - OnInit, Output, + OnInit, + Output, ViewChild, ViewContainerRef } from '@angular/core'; @@ -61,6 +63,21 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy { @Input() hideClearEventAction: boolean = false; + private disableDebugEventActionValue = false; + + get disableDebugEventAction() { + return this.disableDebugEventActionValue; + } + + @Input() + set disableDebugEventAction(value) { + this.disableDebugEventActionValue = value; + if (this.eventTableConfig) { + this.eventTableConfig.disableDebugEventAction = this.disableDebugEventAction; + this.eventTableConfig.updateCellAction(); + } + }; + activeValue = false; dirtyValue = false; entityIdValue: EntityId; @@ -151,7 +168,8 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy { this.store, this.functionTestButtonLabel, this.debugEventSelected, - this.hideClearEventAction + this.hideClearEventAction, + this.disableDebugEventAction ); } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts index 86f01e4cd8..f988d7e6aa 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts @@ -30,7 +30,7 @@ import { TranslateService } from '@ngx-translate/core'; import { DialogService } from '@core/services/dialog.service'; import { MatDialog } from '@angular/material/dialog'; import { Lwm2mBootstrapAddConfigServerDialogComponent } from '@home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component'; -import { mergeMap, takeUntil } from 'rxjs/operators'; +import { filter, mergeMap, takeUntil } from 'rxjs/operators'; import { DeviceProfileService } from '@core/http/device-profile.service'; import { Lwm2mSecurityType } from '@shared/models/lwm2m-security-config.models'; @@ -164,10 +164,8 @@ export class Lwm2mBootstrapConfigServersComponent implements OnInit, ControlValu panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] }).afterClosed(); const addServerConfigObs = addDialogObs.pipe( + filter((isBootstrap) => isBootstrap !== null), mergeMap((isBootstrap) => { - if (isBootstrap === null) { - return of(null); - } return this.deviceProfileService.getLwm2mBootstrapSecurityInfoBySecurityType(isBootstrap, Lwm2mSecurityType.NO_SEC); }) ); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index bf715330a7..357eed49d2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -968,7 +968,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, (AlarmDetailsDialogComponent, { disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', this.ctx.stateController.dashboardCtrl.dashboardCtx.dashboardCssClass, this.ctx.widgetCssClass], data: { alarmId: alarm.id.id, allowAcknowledgment: this.allowAcknowledgment, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts index f7c68fec3d..84d3719ef5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts @@ -113,9 +113,6 @@ export class ColorRangeListComponent implements OnInit, ControlValueAccessor, On this.colorRangeListFormGroup.valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(() => this.updateModel()); - this.colorRangeListFormGroup.get('advancedMode').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe(() => setTimeout(() => {this.popover?.updatePosition();}, 0)); } ngOnDestroy() { @@ -179,7 +176,6 @@ export class ColorRangeListComponent implements OnInit, ControlValueAccessor, On public removeAdvancedRange(index: number) { (this.colorRangeListFormGroup.get('rangeAdvanced') as UntypedFormArray).removeAt(index); - setTimeout(() => {this.popover?.updatePosition();}, 0); } get advancedRangeFormArray(): UntypedFormArray { @@ -193,7 +189,6 @@ export class ColorRangeListComponent implements OnInit, ControlValueAccessor, On removeRange(index: number) { this.rangeListFormArray.removeAt(index); this.colorRangeListFormGroup.markAsDirty(); - setTimeout(() => {this.popover?.updatePosition();}, 0); } rangeDrop(event: CdkDragDrop, range: string) { @@ -216,7 +211,6 @@ export class ColorRangeListComponent implements OnInit, ControlValueAccessor, On const advancedRangeColorsArray = this.colorRangeListFormGroup.get('rangeAdvanced') as UntypedFormArray; const advancedRangeColorControl = this.fb.control(advancedRange, [advancedRangeValidator]); advancedRangeColorsArray.push(advancedRangeColorControl); - setTimeout(() => {this.popover?.updatePosition();}, 0); } addRange() { @@ -228,7 +222,6 @@ export class ColorRangeListComponent implements OnInit, ControlValueAccessor, On }; this.rangeListFormArray.push(this.colorRangeControl(newRange)); this.colorRangeListFormGroup.markAsDirty(); - setTimeout(() => {this.popover?.updatePosition();}, 0); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html index d4e71096b4..bfd6a674b7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html @@ -94,7 +94,7 @@
-
+
{ - this.updateValidators(); - setTimeout(() => {this.popover?.updatePosition();}, 0); - }); this.updateValidators(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts index 9078c8db2b..6eca820e27 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts @@ -117,9 +117,6 @@ export class GradientComponent implements OnInit, ControlValueAccessor, OnDestro this.gradientFormGroup.valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(() => this.updateModel()); - this.gradientFormGroup.get('advancedMode').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe(() => setTimeout(() => {this.popover?.updatePosition();}, 0)); } ngOnDestroy() { @@ -236,7 +233,6 @@ export class GradientComponent implements OnInit, ControlValueAccessor, OnDestro this.gradientListFormArray.removeAt(index); } this.gradientFormGroup.markAsDirty(); - setTimeout(() => {this.popover?.updatePosition();}, 0); } gradientDrop(event: CdkDragDrop, advanced = false) { @@ -255,7 +251,6 @@ export class GradientComponent implements OnInit, ControlValueAccessor, OnDestro this.gradientListFormArray.push(this.colorGradientControl('rgba(0,0,0,0.87)')); } this.gradientFormGroup.markAsDirty(); - setTimeout(() => {this.popover?.updatePosition();}, 0); } updateModel() { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts index 51ed592e7e..6edac690e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts @@ -151,8 +151,8 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O this.widget.widgetContext.containerChangeDetector = this.cd; const cssString = this.widget.widget.config.widgetCss; if (isNotEmptyStr(cssString)) { - this.cssClass = - this.utils.applyCssToElement(this.renderer, this.gridsterItem.el, 'tb-widget-css', cssString); + this.cssClass = this.utils.applyCssToElement(this.renderer, this.gridsterItem.el, 'tb-widget-css', cssString, true); + this.widget.widgetContext.widgetCssClass = this.cssClass; } $(this.gridsterItem.el).on('mousedown', (e) => this.onMouseDown(e.originalEvent)); $(this.gridsterItem.el).on('click', (e) => this.onClicked(e.originalEvent)); diff --git a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html index 655c25545d..ab48435b2d 100644 --- a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html @@ -34,6 +34,7 @@ [tenantId]="data.tenantId" [entityId]="data.entityId" [functionTestButtonLabel]="data.functionTestButtonLabel ?? ''" + [disableDebugEventAction]="data.debugActionDisabled" (debugEventSelected)="this.onDebugEventSelected($event)" />
diff --git a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts index d5339de3b2..8008e74523 100644 --- a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts @@ -23,7 +23,6 @@ import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { DebugEventType, EventType } from '@shared/models/event.models'; import { EntityId } from '@shared/models/id/entity-id'; -import { BaseData, HasId } from '@shared/models/base-data'; export interface EventsDialogData { title: string; @@ -34,7 +33,7 @@ export interface EventsDialogData { disabledEventTypes?: Array; functionTestButtonLabel?: string; onDebugEventSelected?: (event: any, dialogRef: MatDialogRef) => void; - debugActionEnabledFn?: (event: BaseData) => boolean; + debugActionDisabled?: boolean; } @Component({ @@ -54,9 +53,6 @@ export class EventsDialogComponent extends DialogComponent 0) { - this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = this.data.debugActionEnabledFn; - } this.eventsTable.entitiesTable.updateData(); } diff --git a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts index e3d0bb19e6..9e2d22f8a9 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts @@ -20,18 +20,22 @@ import { SafeHtml } from '@angular/platform-browser'; import { PageLink } from '@shared/models/page/page-link'; import { Timewindow } from '@shared/models/time/time.models'; import { EntitiesDataSource } from '@home/models/datasource/entity-datasource'; -import { ElementRef, EventEmitter, Renderer2, ViewContainerRef } from '@angular/core'; +import { ElementRef, EventEmitter, ViewContainerRef } from '@angular/core'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { - CellActionDescriptor, EntityActionTableColumn, EntityColumn, EntityTableColumn, + CellActionDescriptor, + EntityActionTableColumn, + EntityColumn, + EntityTableColumn, EntityTableConfig, GroupActionDescriptor, HeaderActionDescriptor } from '@home/models/entity/entities-table-config.models'; import { ActivatedRoute } from '@angular/router'; +import type { EntityDetailsPanelComponent } from '@home/components/entity/entity-details-panel.component'; export type EntitiesTableAction = 'add'; @@ -64,6 +68,7 @@ export interface IEntitiesTableComponent { paginator: MatPaginator; sort: MatSort; route: ActivatedRoute; + entityDetailsPanel: EntityDetailsPanelComponent; viewContainerRef: ViewContainerRef; addEnabled(): boolean; diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 293381194e..d3349b2ed4 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -308,6 +308,8 @@ export class WidgetContext { widgetNamespace?: string; subscriptionApi?: WidgetSubscriptionApi; + widgetCssClass?: string; + actionsApi?: WidgetActionsApi; activeEntityInfo?: SubscriptionEntityInfo; diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts index 1154fc83b6..00585ffb54 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts @@ -14,14 +14,64 @@ /// limitations under the License. /// -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { DestroyRef, inject, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn, Router, RouterModule, RouterStateSnapshot, Routes } from '@angular/router'; import { Authority } from '@shared/models/authority.enum'; import { AlarmTableComponent } from '@home/components/alarm/alarm-table.component'; import { AlarmsMode } from '@shared/models/alarm.models'; import { MenuId } from '@core/services/menu.models'; import { RouterTabsComponent } from "@home/components/router-tabs.component"; import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rules-table.component"; +import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { CalculatedFieldsTableConfigResolver } from '@home/pages/calculated-fields/calculated-fields-routing.module'; +import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DatePipe } from '@angular/common'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { UtilsService } from '@core/services/utils.service'; +import { AlarmRulesTableConfig } from '@home/components/alarm-rules/alarm-rules-table-config'; + +export const AlarmRulesTableConfigResolver: ResolveFn = + (_route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, + calculatedFieldsService = inject(CalculatedFieldsService), + translate = inject(TranslateService), + dialog = inject(MatDialog), + store = inject(Store), + datePipe = inject(DatePipe), + destroyRef = inject(DestroyRef), + importExportService = inject(ImportExportService), + entityDebugSettingsService = inject(EntityDebugSettingsService), + utilsService = inject(UtilsService), + router = inject(Router), + ) => { + return new AlarmRulesTableConfig( + calculatedFieldsService, + translate, + dialog, + datePipe, + null, + store, + destroyRef, + null, + null, + null, + importExportService, + entityDebugSettingsService, + utilsService, + router, + true, + ); + }; const routes: Routes = [ { @@ -57,15 +107,38 @@ const routes: Routes = [ }, { path: 'alarm-rules', - component: AlarmRulesTableComponent, data: { - auth: [Authority.TENANT_ADMIN], - title: 'alarm-rule.alarm-rules', breadcrumb: { menuId: MenuId.alarm_rules }, - isPage: true, - } + }, + children: [ + { + path: '', + component: AlarmRulesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'alarm-rule.alarm-rules', + isPage: true, + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'tune' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'entity.type-calculated-fields', + }, + resolve: { + entitiesTableConfig: AlarmRulesTableConfigResolver + } + } + ] } ] } diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.html b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.html new file mode 100644 index 0000000000..7cabcbf5b8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.html @@ -0,0 +1,30 @@ + +@if (entity) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts new file mode 100644 index 0000000000..c239cfbd4d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts @@ -0,0 +1,49 @@ +/// +/// 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 } from '@angular/core'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; +import type { + CalculatedFieldsTableConfig, + CalculatedFieldsTableEntity +} from '@home/components/calculated-fields/calculated-fields-table-config'; +import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; + +@Component({ + selector: 'tb-alarm-rules-tabs', + templateUrl: './alarm-rules-tabs.component.html', + styleUrls: [] +}) +export class AlarmRulesTabsComponent extends EntityTabsComponent { + + readonly DebugEventType = DebugEventType; + readonly EventType = EventType; + + constructor() { + super(); + } + + get debugActionDisabled(): boolean { + return !debugCfActionEnabled(this.entity); + }; + + onDebugEventSelected(event: CalculatedFieldEventBody) { + (this.entitiesTableConfig as CalculatedFieldsTableConfig).getTestScriptDialog(this.entity, JSON.parse(event.arguments)) + .subscribe((expression) => { + }); + }; +} diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts index 312259b217..500c6bf47b 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts @@ -20,9 +20,12 @@ import { SharedModule } from '@shared/shared.module'; import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { AlarmRoutingModule } from '@home/pages/alarm/alarm-routing.module'; +import { AlarmRulesTabsComponent } from '@home/pages/alarm/alarm-rules-tabs.component'; @NgModule({ - declarations: [], + declarations: [ + AlarmRulesTabsComponent + ], imports: [ CommonModule, SharedModule, diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields.module.ts b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-field-page.module.ts similarity index 77% rename from ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields.module.ts rename to ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-field-page.module.ts index 01ed3577e8..f0672c2e5e 100644 --- a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields.module.ts +++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-field-page.module.ts @@ -20,15 +20,19 @@ import { SharedModule } from '@shared/shared.module'; import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { CalculatedFieldsRoutingModule } from '@home/pages/calculated-fields/calculated-fields-routing.module'; +import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; +import { CalculatedFieldsTabsComponent } from '@home/pages/calculated-fields/calculated-fields-tabs.component'; @NgModule({ - declarations: [], + declarations: [ + CalculatedFieldsTabsComponent + ], imports: [ CommonModule, SharedModule, HomeComponentsModule, HomeDialogsModule, - CalculatedFieldsRoutingModule + CalculatedFieldsRoutingModule, ] }) -export class CalculatedFieldsModule { } \ No newline at end of file +export class CalculatedFieldPageModule { } \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts index 0ed435075c..e703445155 100644 --- a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts @@ -14,24 +14,94 @@ /// limitations under the License. /// -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { DestroyRef, inject, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn, Router, RouterModule, RouterStateSnapshot, Routes } from '@angular/router'; import { Authority } from '@shared/models/authority.enum'; import { MenuId } from '@core/services/menu.models'; import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DatePipe } from '@angular/common'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { UtilsService } from '@core/services/utils.service'; + +export const CalculatedFieldsTableConfigResolver: ResolveFn = + (_route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, + calculatedFieldsService = inject(CalculatedFieldsService), + translate = inject(TranslateService), + dialog = inject(MatDialog), + store = inject(Store), + datePipe = inject(DatePipe), + destroyRef = inject(DestroyRef), + importExportService = inject(ImportExportService), + entityDebugSettingsService = inject(EntityDebugSettingsService), + utilsService = inject(UtilsService), + router = inject(Router), + ) => { + return new CalculatedFieldsTableConfig( + calculatedFieldsService, + translate, + dialog, + datePipe, + null, + store, + destroyRef, + null, + null, + null, + importExportService, + entityDebugSettingsService, + utilsService, + router, + true, + ); + }; const routes: Routes = [ { path: 'calculatedFields', - component: CalculatedFieldsTableComponent, data: { - auth: [Authority.TENANT_ADMIN], - title: 'entity.type-calculated-fields', breadcrumb: { menuId: MenuId.calculated_fields + } + }, + children: [ + { + path: '', + component: CalculatedFieldsTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'entity.type-calculated-fields', + isPage: true, + } }, - isPage: true, - } + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'mdi:function-variant' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'entity.type-calculated-fields', + }, + resolve: { + entitiesTableConfig: CalculatedFieldsTableConfigResolver + } + } + ] } ]; diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.html b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.html new file mode 100644 index 0000000000..7cabcbf5b8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.html @@ -0,0 +1,30 @@ + +@if (entity) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts new file mode 100644 index 0000000000..54e26db1be --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts @@ -0,0 +1,57 @@ +/// +/// 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 } from '@angular/core'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; +import type { + CalculatedFieldsTableConfig, + CalculatedFieldsTableEntity +} from '@home/components/calculated-fields/calculated-fields-table-config'; +import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; + +@Component({ + selector: 'tb-calculated-fields-tabs', + templateUrl: './calculated-fields-tabs.component.html', + styleUrls: [] +}) +export class CalculatedFieldsTabsComponent extends EntityTabsComponent { + + readonly DebugEventType = DebugEventType; + readonly EventType = EventType; + + constructor() { + super(); + } + + get debugActionDisabled(): boolean { + return !debugCfActionEnabled(this.entity); + }; + + onDebugEventSelected(event: CalculatedFieldEventBody) { + (this.entitiesTableConfig as CalculatedFieldsTableConfig).getTestScriptDialog(this.entity, JSON.parse(event.arguments), false) + .subscribe((expression) => { + (this.entitiesTableConfig as CalculatedFieldsTableConfig).getTable(); + const entityDetailsPanel = this.entitiesTableConfig.getTable().entityDetailsPanel; + entityDetailsPanel.onToggleEditMode(true); + entityDetailsPanel.selectedTab = 0; + setTimeout(() => { + entityDetailsPanel.detailsForm.get('configuration').setValue({...this.entity.configuration, expression}); + entityDetailsPanel.detailsForm.get('configuration').markAsDirty(); + }); + }); + }; +} diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index c43b89d141..d84b646525 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -27,7 +27,7 @@ import { UserModule } from '@modules/home/pages/user/user.module'; import { DeviceModule } from '@modules/home/pages/device/device.module'; import { AssetModule } from '@modules/home/pages/asset/asset.module'; import { EntityViewModule } from '@modules/home/pages/entity-view/entity-view.module'; -import { CalculatedFieldsModule } from '@home/pages/calculated-fields/calculated-fields.module'; +import { CalculatedFieldPageModule } from '@home/pages/calculated-fields/calculated-field-page.module'; import { RuleChainModule } from '@modules/home/pages/rulechain/rulechain.module'; import { WidgetLibraryModule } from '@modules/home/pages/widget/widget-library.module'; import { DashboardModule } from '@modules/home/pages/dashboard/dashboard.module'; @@ -70,7 +70,7 @@ import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; EdgeModule, EntityViewModule, CustomerModule, - CalculatedFieldsModule, + CalculatedFieldPageModule, RuleChainModule, WidgetLibraryModule, DashboardModule, diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html index 06056a984c..c2a84773c8 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html @@ -21,9 +21,9 @@ login.create-password
- + - +
@@ -53,10 +53,10 @@
- -
diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts index 0f2ec26ad3..6e9be088c7 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts @@ -20,13 +20,15 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { UserPasswordPolicy } from '@shared/models/settings.models'; import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models'; +import { finalize } from 'rxjs/operators'; +import { PageComponent } from '@shared/components/page.component'; @Component({ selector: 'tb-create-password', templateUrl: './create-password.component.html', styleUrls: ['./password.component.scss'] }) -export class CreatePasswordComponent { +export class CreatePasswordComponent extends PageComponent { passwordPolicy: UserPasswordPolicy; createPassword: FormGroup; @@ -38,7 +40,7 @@ export class CreatePasswordComponent { constructor(private route: ActivatedRoute, private authService: AuthService, private fb: FormBuilder) { - + super(); this.activateToken = this.route.snapshot.queryParams['activateToken'] || ''; this.passwordPolicy = this.route.snapshot.data['passwordPolicy']; @@ -60,11 +62,10 @@ export class CreatePasswordComponent { if (this.createPassword.invalid) { this.createPassword.markAllAsTouched(); } else { - this.isLoading = true - this.authService.activate(this.activateToken, this.createPassword.get('newPassword').value, true) - .subscribe({ - error: () => {this.isLoading = false;} - }); + this.isLoading = true; + this.authService.activate(this.activateToken, this.createPassword.get('newPassword').value, true).pipe( + finalize(() => {this.isLoading = false;}) + ).subscribe(() => {}); } } } diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.html b/ui-ngx/src/app/modules/login/pages/login/login.component.html index f43ff18435..f2878bde27 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.html @@ -22,9 +22,9 @@
- + - +
@if(oauth2Clients?.length) { @@ -70,7 +70,7 @@ lock -
diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index 5e75426952..f20c7b5a4d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { AfterViewInit, Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -26,6 +26,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatFormFieldAppearance } from '@angular/material/form-field'; +import { BaseData } from '@shared/models/base-data'; @Component({ selector: 'tb-entity-select', @@ -37,7 +38,7 @@ import { MatFormFieldAppearance } from '@angular/material/form-field'; multi: true }] }) -export class EntitySelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { +export class EntitySelectComponent implements ControlValueAccessor, OnInit { entitySelectFormGroup: UntypedFormGroup; @@ -66,16 +67,26 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte @coerceBoolean() useEntityDisplayName = false; + @Input() + filterAllowedEntityTypes = true; + + @Input() + defaultEntityType: AliasEntityType | EntityType; + + @Input() + entityTypeLabel: string; + + @Output() + entityChanged = new EventEmitter>(); + displayEntityTypeSelect: boolean; AliasEntityType = AliasEntityType; - entityTypeNullUUID: Set = new Set([ + entityTypeNullUUID = new Set([ AliasEntityType.CURRENT_TENANT, AliasEntityType.CURRENT_USER, AliasEntityType.CURRENT_USER_OWNER ]); - private readonly defaultEntityType: EntityType | AliasEntityType = null; - private propagateChange = (v: any) => { }; constructor(private store: Store, @@ -86,15 +97,18 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte const entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, this.useAliasEntityTypes); + + let defaultEntityType: EntityType | AliasEntityType = null + if (entityTypes.length === 1) { this.displayEntityTypeSelect = false; - this.defaultEntityType = entityTypes[0]; + defaultEntityType = entityTypes[0]; } else { this.displayEntityTypeSelect = true; } this.entitySelectFormGroup = this.fb.group({ - entityType: [this.defaultEntityType], + entityType: [defaultEntityType], entityId: [null] }); } @@ -126,9 +140,19 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte if (additionNullUIIDEntityTypes.length > 0) { additionNullUIIDEntityTypes.forEach((entityType) => this.entityTypeNullUUID.add(entityType)); } - } - ngAfterViewInit(): void { + if (this.filterAllowedEntityTypes === false) { + if (this.allowedEntityTypes?.length === 1) { + this.displayEntityTypeSelect = false; + this.entitySelectFormGroup.get('entityType').setValue(this.allowedEntityTypes[0]) + } else { + this.displayEntityTypeSelect = true; + } + } + + if (this.defaultEntityType) { + this.entitySelectFormGroup.get('entityType').setValue(this.defaultEntityType); + } } setDisabledState(isDisabled: boolean): void { @@ -176,4 +200,8 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte } } } + + changeEntity(entity: BaseData): void { + this.entityChanged.emit(entity); + } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index fcfe158da0..dcabc49906 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -52,8 +52,16 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @coerceBoolean() showLabel: boolean; + private labelValue = this.translate.instant('entity.type'); + + get label(): string { + return this.labelValue; + } + @Input() - label = this.translate.instant('entity.type'); + set label(value: string) { + this.labelValue = value ?? this.translate.instant('entity.type'); + } @Input() @coerceBoolean() diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.html b/ui-ngx/src/app/shared/components/notification/notification.component.html index 674a074a85..0a667ff334 100644 --- a/ui-ngx/src/app/shared/components/notification/notification.component.html +++ b/ui-ngx/src/app/shared/components/notification/notification.component.html @@ -42,7 +42,7 @@ check_circle_outline
+ [style.background-color]="alarmColorSeverityBackground()"> {{alarmSeverityTranslations.get(notification.info.alarmSeverity) | translate}} diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.ts b/ui-ngx/src/app/shared/components/notification/notification.component.ts index 77be7ff7cd..9e7398e457 100644 --- a/ui-ngx/src/app/shared/components/notification/notification.component.ts +++ b/ui-ngx/src/app/shared/components/notification/notification.component.ts @@ -17,7 +17,6 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { ActionButtonLinkType, - AlarmSeverityNotificationColors, Notification, NotificationStatus, NotificationType, @@ -25,7 +24,11 @@ import { } from '@shared/models/notification.models'; import { UtilsService } from '@core/services/utils.service'; import { Router } from '@angular/router'; -import { alarmSeverityTranslations } from '@shared/models/alarm.models'; +import { + alarmSeverityBackgroundColors, + alarmSeverityColors, + alarmSeverityTranslations +} from '@shared/models/alarm.models'; import tinycolor from 'tinycolor2'; import { StateObject } from '@core/api/widget-api.models'; import { objToBase64URI } from '@core/utils'; @@ -134,13 +137,13 @@ export class NotificationComponent implements OnInit { } } - alarmColorSeverity(alpha: number) { - return tinycolor(AlarmSeverityNotificationColors.get(this.notification.info.alarmSeverity)).setAlpha(alpha).toRgbString(); + alarmColorSeverityBackground() { + return alarmSeverityBackgroundColors.get(this.notification.info.alarmSeverity); } notificationColor(): string { if (this.notification.type === NotificationType.ALARM && !this.notification.info.cleared) { - return AlarmSeverityNotificationColors.get(this.notification.info.alarmSeverity); + return alarmSeverityColors.get(this.notification.info.alarmSeverity); } return 'transparent'; } @@ -154,7 +157,7 @@ export class NotificationComponent implements OnInit { notificationIconColor(): object { if (this.notification.type === NotificationType.ALARM) { - return {color: AlarmSeverityNotificationColors.get(this.notification.info.alarmSeverity)}; + return {color: alarmSeverityColors.get(this.notification.info.alarmSeverity)}; } else if (this.notification.type === NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT) { return {color: '#D12730'}; } else if (this.notification.type === NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST) { diff --git a/ui-ngx/src/app/shared/models/alarm-rule.models.ts b/ui-ngx/src/app/shared/models/alarm-rule.models.ts index df137313be..5458d3ab77 100644 --- a/ui-ngx/src/app/shared/models/alarm-rule.models.ts +++ b/ui-ngx/src/app/shared/models/alarm-rule.models.ts @@ -21,7 +21,11 @@ import { TimeUnit } from "@shared/models/time/time.models"; import { ComplexOperation, EntityKeyValueType, FilterPredicateType } from "@shared/models/query/query.models"; import { EntityType } from "@shared/models/entity-type.models"; import { Observable } from "rxjs"; -import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + CalculatedField, + CalculatedFieldArgument, + CalculatedFieldEventArguments +} from "@shared/models/calculated-field.models"; export const alarmRuleEntityTypeList = [EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE]; @@ -244,7 +248,7 @@ export const alarmRuleDefaultScript = '// Triggers when temperature is above 20 degrees\n' + 'return temperature > 20;' -export type AlarmRuleTestScriptFn = (calculatedField: CalculatedField, expression: string, argumentsObj?: Record, closeAllOnSave?: boolean) => Observable; +export type AlarmRuleTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit?: boolean, expression?: string) => Observable; export function checkPredicates(predicates: any[], validSet: Set): boolean { for (const predicate of predicates) { diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index 5e1e5def25..76199faf2c 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -88,11 +88,21 @@ export const alarmSearchStatusTranslations = new Map( export const alarmSeverityColors = new Map( [ - [AlarmSeverity.CRITICAL, 'red'], - [AlarmSeverity.MAJOR, 'orange'], - [AlarmSeverity.MINOR, '#ffca3d'], - [AlarmSeverity.WARNING, '#abab00'], - [AlarmSeverity.INDETERMINATE, 'green'] + [AlarmSeverity.CRITICAL, 'var(--tb-alarm-severity-critical, rgb(209, 39, 48))'], + [AlarmSeverity.MAJOR, 'var(--tb-alarm-severity-major, rgb(246, 103, 22))'], + [AlarmSeverity.MINOR, 'var(--tb-alarm-severity-minor, rgb(250, 164, 5))'], + [AlarmSeverity.WARNING, 'var(--tb-alarm-severity-warning, rgb(242, 218, 5))'], + [AlarmSeverity.INDETERMINATE, 'var(--tb-alarm-severity-indeterminate, rgba(0, 0, 0, 0.38))'] + ] +); + +export const alarmSeverityBackgroundColors = new Map( + [ + [AlarmSeverity.CRITICAL, `var(--tb-alarm-severity-critical-bg, rgba(209, 39, 48, 0.06))`], + [AlarmSeverity.MAJOR, 'var(--tb-alarm-severity-major-bg, rgba(246, 103, 22, 0.06))'], + [AlarmSeverity.MINOR, 'var(--tb-alarm-severity-minor-bg, rgba(250, 164, 5, 0.06))'], + [AlarmSeverity.WARNING, 'var(--tb-alarm-severity-warning-bg, rgba(242, 218, 5, 0.06))'], + [AlarmSeverity.INDETERMINATE, 'var(--tb-alarm-severity-indeterminate-bg, rgba(0, 0, 0, 0.06))'] ] ); diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 825c08a69f..30f5a27686 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -540,6 +540,12 @@ export const getCalculatedFieldCurrentEntityFilter = (entityName: string, entity } } +export const debugCfActionEnabled = (cf: CalculatedField) => { + return (cf.type === CalculatedFieldType.SCRIPT || + (cf.type === CalculatedFieldType.PROPAGATION && cf.configuration.applyExpressionToResolvedArguments) + ); +} + export interface CalculatedFieldArgumentValueBase { argumentName: string; type: ArgumentType; diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 48428932e4..bc0d3fd470 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -490,7 +490,7 @@ export const entityTypeTranslations = new Map([ [NotificationType.RESOURCES_SHORTAGE, 'warning'] ]); -export const AlarmSeverityNotificationColors = new Map( - [ - [AlarmSeverity.CRITICAL, '#D12730'], - [AlarmSeverity.MAJOR, '#FEAC0C'], - [AlarmSeverity.MINOR, '#F2DA05'], - [AlarmSeverity.WARNING, '#F66716'], - [AlarmSeverity.INDETERMINATE, '#00000061'] - ] -); - export enum ActionButtonLinkType { LINK = 'LINK', DASHBOARD = 'DASHBOARD' diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md index 1d45dea3c8..6129dac351 100644 --- a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md +++ b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md @@ -11,12 +11,16 @@ function calculate(ctx, arg1, arg2, ...): object | object[] ### Supported Arguments +Arguments are passed to the function by **name** defined in the calculated field configuration. + There are three types of arguments supported in the calculated field configuration: #### Attribute and Latest Telemetry Arguments These arguments are single values and may be of type: boolean, int64 (long), double, string, or JSON. +#### Direct argument access via **``** + **Example: Convert Temperature from Fahrenheit to Celsius** ```javascript @@ -26,7 +30,9 @@ return { } ``` -Alternatively, using `ctx` to access the argument as an object: +#### Accessing argument via **`ctx.args.`** + +In addition to direct access, arguments can be accessed via the `ctx.args.` object, which includes both the `value` of an argument and its timestamp as `ts`: ```json { @@ -37,7 +43,6 @@ Alternatively, using `ctx` to access the argument as an object: } ``` -You may notice that the object includes both the `value` of an argument and its timestamp as `ts`. Let's modify the function that converts Fahrenheit to Celsius to also return the timestamp information: ```javascript diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md index ce8ca22f51..22316fe418 100644 --- a/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md +++ b/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md @@ -1,10 +1,70 @@ ## 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. +The **filter()** function is a [TBEL](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/) script used in aggregation metrics of a calculated field. + It receives arguments configured in the calculated field setup, along with an additional `ctx` object that stores `latestTs` and provides access to all arguments. +It allows you to include or exclude related entities from aggregation based on their telemetry or attribute values. + +The filter is evaluated per related entity before the aggregation function is applied. + ### Function Signature ```javascript -function calculate(ctx, arg1, arg2, ...): boolean +function filter(ctx, arg1, arg2, ...): boolean +``` + +### Supported Arguments + +Arguments are passed to the function by **name** defined in the calculated field configuration. + +There are two types of arguments supported in the calculated field configuration: **Attribute and Latest Telemetry Arguments** + +These arguments are single values and may be of type: boolean, int64 (long), double, string, or JSON. + +#### Direct argument access via **``** + +**Example: Count free parking spaces** + +**Goal**: Include only parking spaces that are active and not occupied. + +```javascript +return active == true && occupied == false; +``` + +Only entities that satisfy this condition will be included in the aggregation. + +#### Accessing argument via **`ctx.args.`** + +In addition to direct access, arguments can be accessed via the `ctx.args.` object, which includes both the `value` of an argument and its timestamp as `ts`: + +```json +{ + "consumption": { + "ts": 1740644656669, + "value": 542.6 + } +} +``` + +The `ctx.latestTs` property represents the latest timestamp across all related entities and their arguments participating in the aggregation. + +**Example: Calculate the total consumption across multiple related pumps** + +**Scenario**: Each pump reports consumption telemetry approximately every 10 minutes, but reporting times may vary due to network delays (up to ~30 seconds). +To avoid counting outdated values, only recently updated telemetry should be included. + +**Goal**: Include only pumps whose consumption value was reported within 1 minute of the latest timestamp. + +```javascript +var ONE_MINUTE = 60 * 1000; +return (ctx.latestTs - ctx.args.consumption.ts) <= ONE_MINUTE; ``` + +### Function return format + +The function **must** return a boolean: +- `true` → include entity in aggregation +- `false` → exclude entity from aggregation + +Any other return type is considered invalid. diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/map_expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/map_expression_fn.md new file mode 100644 index 0000000000..41ccf3540b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/map_expression_fn.md @@ -0,0 +1,79 @@ +## Calculated Field TBEL Map Function + +The **map()** function is a [TBEL](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/) script used in aggregation metrics of a calculated field. + +It determines the value applied by each related entity to the aggregation. + +The function receives arguments configured in the calculated field setup, along with an additional `ctx` object that stores `latestTs` and provides access to all arguments. + +The function is evaluated **per related entity**, after filtering is applied. + +### Function Signature + +```javascript +function map(ctx, arg1, arg2, ...): number | boolean | string +``` + +### Supported Arguments + +Arguments are passed to the function by **name** defined in the calculated field configuration. + +There are two types of arguments supported in the calculated field configuration: **Attribute and Latest Telemetry Arguments** + +These arguments are single values and may be of type: boolean, int64 (long), double, string, or JSON. + +#### Direct argument access via **``** + +**Example: Calculate average temperature across sensors** + +**Scenario**: Multiple related sensors report temperature in Fahrenheit. You want to aggregate temperature values, but the aggregation must be performed in Celsius. + +**Goal**: Convert temperature to Celsius before aggregation + +```javascript +var temperatureC = (temperature - 32) / 1.8; +return toFixed(temperatureC, 2); +``` + +Instead of creating a separate calculated field for conversion, the transformation is performed per entity, before aggregation. + +#### Accessing argument via **`ctx.args.`** + +In addition to direct access, arguments can be accessed via the `ctx.args.` object, which includes both the `value` of an argument and its timestamp as `ts`: + +```json +{ + "temperature": { + "ts": 1740644656761, + "value": 33.6 + } +} +``` + +The `ctx.latestTs` property represents the latest timestamp across all related entities and their arguments participating in the aggregation. + +**Example: Calculate average temperature** + +**Scenario**: Each sensor reports temperature approximately every 10 minutes, but reporting times may vary due to network delays (up to ~30 seconds). +If a temperature value is outdated, you want the entity to remain part of the aggregation, but contribute a default value instead of a stale one. + +**Goal**: Return a default value when the temperature was not reported within 1 minute of the latest aggregation timestamp. + +```javascript +var ONE_MINUTE = 60 * 1000; +if ((ctx.latestTs - ctx.args.temperature.ts) > ONE_MINUTE) { + return 0; +}; +return temperature; +``` + +### Function return format + +The returned value is passed directly to the aggregation engine: + +| Aggregation function | Expected return value | +|--------------------------------|-----------------------| +| Count, Count unique | any value | +| Sum, Average, Minimum, Maximum | number | + +Returning a value of an incompatible type may result in the entity being ignored or the aggregation producing incorrect results. diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index e023179dd3..259008791e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1086,7 +1086,7 @@ "filter-types": "Audit log types" }, "debug-settings": { - "label": "Debug Configuration", + "label": "Debug configuration", "on-failure": "Failures only (24/7)", "all-messages": "All messages ({{time}})", "failures": "Failures", @@ -1115,6 +1115,7 @@ "arguments": "Arguments", "decimals-by-default": "Decimals by default", "debugging": "Calculated field debugging", + "calculated-field-details": "Calculated field details", "argument-name": "Argument name", "name": "Name", "datasource": "Datasource", @@ -1182,7 +1183,7 @@ "perimeter-attribute-key": "Perimeter attribute key", "perimeter-attribute-key-required": "Perimeter attribute key is required.", "perimeter-attribute-key-pattern": "Perimeter attribute key is invalid.", - "entity-zone-relationship": "Path from Entity to Zones *", + "entity-zone-relationship": "Path from Entity to Zones", "direction": "Relation direction", "direction-from": "From entity to zone", "direction-to": "From zone to entity", @@ -1220,6 +1221,7 @@ "calculated-field-filter-title": "Calculated field filter", "filter-title": "Filter", "calculated-field-types": "Calculated field types", + "events": "Events", "any-type": "Any type", "metrics": { "metrics": "Metrics", @@ -1392,6 +1394,7 @@ "create": "Create new alarm rule", "add": "Add alarm rule", "copy": "Copy alarm rule configuration", + "details": "Alarm rule details", "no-found": "No alarm rules found", "list": "{ count, plural, =1 {One alarm rule} other {List of # alarm rules} }", "selected-fields": "{ count, plural, =1 {1 alarm rule} other {# alarm rules} } selected",