From c75e9d96e5c1726a22660f051a2bad7c622869f0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 12 Nov 2025 16:10:32 +0200 Subject: [PATCH 1/7] Add API to find calculated fields by filter --- .../controller/CalculatedFieldController.java | 97 ++++++++++++++----- .../server/controller/AbstractWebTest.java | 2 +- .../CalculatedFieldControllerTest.java | 51 +++++++++- .../server/dao/cf/CalculatedFieldService.java | 4 + .../common/data/cf/CalculatedFieldFilter.java | 41 ++++++++ .../common/data/cf/CalculatedFieldInfo.java | 34 +++++++ .../dao/cf/BaseCalculatedFieldService.java | 20 ++++ .../server/dao/cf/CalculatedFieldDao.java | 3 + .../dao/sql/cf/CalculatedFieldRepository.java | 9 ++ .../dao/sql/cf/JpaCalculatedFieldDao.java | 12 +++ 10 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldInfo.java diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 9a24ab91fc..46abed29c8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -44,6 +44,8 @@ import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.event.EventType; @@ -59,14 +61,18 @@ import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import java.util.ArrayList; import java.util.Collections; +import java.util.EnumSet; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; @@ -99,30 +105,30 @@ public class CalculatedFieldController extends BaseController { private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n" - + MARKDOWN_CODE_BLOCK_START - + "{\n" + - " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + - " \"arguments\": {\n" + - " \"temperature\": {\n" + - " \"type\": \"TS_ROLLING\",\n" + - " \"timeWindow\": {\n" + - " \"startTs\": 1739775630002,\n" + - " \"endTs\": 65432211,\n" + - " \"limit\": 5\n" + - " },\n" + - " \"values\": [\n" + - " { \"ts\": 1739775639851, \"value\": 23 },\n" + - " { \"ts\": 1739775664561, \"value\": 43 },\n" + - " { \"ts\": 1739775713079, \"value\": 15 },\n" + - " { \"ts\": 1739775999522, \"value\": 34 },\n" + - " { \"ts\": 1739776228452, \"value\": 22 }\n" + - " ]\n" + - " },\n" + - " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + - " }\n" + - "}" - + MARKDOWN_CODE_BLOCK_END - + "\n\n Expected result JSON contains \"output\" and \"error\"."; + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + + " \"arguments\": {\n" + + " \"temperature\": {\n" + + " \"type\": \"TS_ROLLING\",\n" + + " \"timeWindow\": {\n" + + " \"startTs\": 1739775630002,\n" + + " \"endTs\": 65432211,\n" + + " \"limit\": 5\n" + + " },\n" + + " \"values\": [\n" + + " { \"ts\": 1739775639851, \"value\": 23 },\n" + + " { \"ts\": 1739775664561, \"value\": 43 },\n" + + " { \"ts\": 1739775713079, \"value\": 15 },\n" + + " { \"ts\": 1739775999522, \"value\": 34 },\n" + + " { \"ts\": 1739776228452, \"value\": 22 }\n" + + " ]\n" + + " },\n" + + " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Expected result JSON contains \"output\" and \"error\"."; @ApiOperation(value = "Create Or Update Calculated Field (saveCalculatedField)", notes = "Creates or Updates the Calculated Field. When creating calculated field, platform generates Calculated Field Id as " + UUID_WIKI_LINK + @@ -183,6 +189,46 @@ public class CalculatedFieldController extends BaseController { return checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, type, pageLink)); } + @ApiOperation(value = "Get calculated fields (getCalculatedFields)", + notes = "Fetch tenant calculated fields based on the filter.") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @GetMapping(value = "/calculatedFields") + public PageData getCalculatedFields(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Calculated field type filter.") + @RequestParam CalculatedFieldType type, + @Parameter(description = "Entity type filter. If not specified, calculated fields for all supported entity types will be returned.") + @RequestParam(required = false) EntityType entityType, + @Parameter(description = "Entities filter. If not specified, calculated fields for entity type filter will be returned.") + @RequestParam(required = false) List entities, + @Parameter(description = "Name filter.") + @RequestParam(required = false) String name, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + SecurityUser user = getCurrentUser(); + Set entityTypes; + if (entityType == null) { + entityTypes = CalculatedField.SUPPORTED_ENTITIES.keySet(); + } else { + entityTypes = EnumSet.of(entityType); + } + + CalculatedFieldFilter filter = CalculatedFieldFilter.builder() + .type(type) + .entityTypes(entityTypes) + .entityIds(entities) + .name(name) + .build(); + return calculatedFieldService.findCalculatedFieldsByTenantIdAndFilter(user.getTenantId(), filter, pageLink); + } + @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)", notes = "Deletes the calculated field. Referencing non-existing Calculated Field Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @@ -295,7 +341,8 @@ public class CalculatedFieldController extends BaseController { return; } case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); + default -> + throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } } } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 6f1446049f..28c215ae08 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -1370,7 +1370,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return doPost("/api/calculatedField", calculatedField, CalculatedField.class); } - protected PageData getCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { + protected PageData getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); } diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 4ebace6ae7..cf398bb693 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -15,13 +15,17 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.core.type.TypeReference; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; @@ -36,6 +40,7 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoor import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @@ -45,6 +50,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.Map; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -192,17 +198,56 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { } @Test - public void testGetCalculatedFields() throws Exception { + public void testGetEntityCalculatedFields() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); - assertThat(getCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData()) + assertThat(getEntityCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData()) .singleElement().isEqualTo(calculatedField); - assertThat(getCalculatedFields(testDevice.getId(), CalculatedFieldType.SIMPLE, new PageLink(10)).getData()) + assertThat(getEntityCalculatedFields(testDevice.getId(), CalculatedFieldType.SIMPLE, new PageLink(10)).getData()) .singleElement().isEqualTo(calculatedField); } + @Test + public void testGetCalculatedFieldsByFilter() throws Exception { + Device device = createDevice("Device A", "1234567890"); + CalculatedFieldInfo deviceCalculatedField = new CalculatedFieldInfo( + doPost("/api/calculatedField", getSimpleCalculatedField(device.getId()), CalculatedField.class), + "Device A" + ); + + DeviceProfile deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Profile A"), DeviceProfile.class); + CalculatedFieldInfo profileCalculatedField = new CalculatedFieldInfo( + doPost("/api/calculatedField", getSimpleCalculatedField(deviceProfile.getId()), CalculatedField.class), + "Profile A" + ); + + List allCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, null); + assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); + + List profileLevelCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, EntityType.DEVICE_PROFILE, null, null); + assertThat(profileLevelCalculatedFields).containsOnly(profileCalculatedField); + + List specificDeviceCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, EntityType.DEVICE, List.of(device.getUuidId()), null); + assertThat(specificDeviceCalculatedFields).containsOnly(deviceCalculatedField); + + List byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, deviceCalculatedField.getName()); + assertThat(byNameCalculatedFields).containsOnly(deviceCalculatedField); + } + + private List getCalculatedFields(CalculatedFieldType type, + EntityType entityType, + List entities, + String name) throws Exception { + return doGetTypedWithPageLink("/api/calculatedFields?type=" + type + "&" + + (entityType != null ? "entityType=" + entityType + "&" : "") + + (entities != null ? "entities=" + String.join(",", + entities.stream().map(UUID::toString).toList()) + "&" : "") + + (name != null ? "name=" + name + "&" : ""), + new TypeReference>() {}, new PageLink(10)).getData(); + } + @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java index bef4675e03..86bb877964 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -16,6 +16,8 @@ package org.thingsboard.server.dao.cf; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -44,6 +46,8 @@ public interface CalculatedFieldService extends EntityDaoService { PageData findCalculatedFieldsByTenantId(TenantId tenantId, PageLink pageLink); + PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink); + PageData findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink); void deleteCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java new file mode 100644 index 0000000000..e99ceeeed9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf; + +import lombok.Builder; +import lombok.Data; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.thingsboard.server.common.data.EntityType; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Data +@Builder +public class CalculatedFieldFilter { + + @NonNull + private final CalculatedFieldType type; + @NonNull + private final Set entityTypes; + @Nullable + private final List entityIds; + @Nullable + private final String name; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldInfo.java new file mode 100644 index 0000000000..b72613e86f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldInfo.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class CalculatedFieldInfo extends CalculatedField { + + private String entityName; + + public CalculatedFieldInfo(CalculatedField calculatedField, String entityName) { + super(calculatedField); + this.entityName = entityName; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index d3ca3ccff3..b184c85cd4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -19,8 +19,11 @@ import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -30,6 +33,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; @@ -37,8 +41,10 @@ import org.thingsboard.server.dao.service.validator.CalculatedFieldDataValidator import java.util.EnumSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -53,6 +59,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements public static final String INCORRECT_CALCULATED_FIELD_ID = "Incorrect calculatedFieldId "; public static final String INCORRECT_ENTITY_ID = "Incorrect entityId "; + private final EntityService entityService; private final CalculatedFieldDao calculatedFieldDao; private final CalculatedFieldDataValidator calculatedFieldDataValidator; @@ -140,6 +147,19 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return calculatedFieldDao.findAllByTenantId(tenantId, pageLink); } + @Override + public PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink) { + PageData calculatedFields = calculatedFieldDao.findByTenantIdAndFilter(tenantId, filter, pageLink); + Set entityIds = calculatedFields.getData().stream() + .map(CalculatedField::getEntityId) + .collect(Collectors.toSet()); + Map entityInfos = entityService.fetchEntityInfos(tenantId, null, entityIds); + return calculatedFields.mapData(calculatedField -> { + EntityInfo entityInfo = entityInfos.get(calculatedField.getEntityId()); + return new CalculatedFieldInfo(calculatedField, entityInfo.getName()); + }); + } + @Override public PageData findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink) { log.trace("Executing findAllByEntityId, entityId [{}], pageLink [{}]", entityId, pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java index 9e8ee61d4c..c93e770b8e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.cf; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -49,4 +50,6 @@ public interface CalculatedFieldDao extends Dao { long countByEntityIdAndTypeNot(TenantId tenantId, EntityId entityId, CalculatedFieldType type); + PageData findByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index 6725387fba..31478666c2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -42,6 +42,15 @@ public interface CalculatedFieldRepository extends JpaRepository findByTenantIdAndEntityIdAndTypes(UUID tenantId, UUID entityId, List types, String textSearch, Pageable pageable); + @Query("SELECT cf FROM CalculatedFieldEntity cf WHERE cf.tenantId = :tenantId " + + "AND cf.type = :type " + + "AND cf.entityType IN :entityTypes " + + "AND (:entityIds IS NULL OR cf.entityId IN :entityIds) " + + "AND (:name IS NULL OR cf.name = :name) " + + "AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)") + Page findByTenantIdAndFilter(UUID tenantId, String type, List entityTypes, + List entityIds, String name, String textSearch, Pageable pageable); + List findAllByTenantId(UUID tenantId); List removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index 3cd9285d78..a2a95bdf77 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -15,13 +15,16 @@ */ package org.thingsboard.server.dao.sql.cf; +import com.google.common.base.Strings; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -102,6 +105,15 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao findByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink) { + return DaoUtil.toPageData(calculatedFieldRepository.findByTenantIdAndFilter(tenantId.getId(), filter.getType().name(), + filter.getEntityTypes().stream().map(Enum::name).toList(), + CollectionUtils.isNotEmpty(filter.getEntityIds()) ? filter.getEntityIds() : null, + Strings.emptyToNull(filter.getName()), + pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); + } + @Override protected Class getEntityClass() { return CalculatedFieldEntity.class; From a4146ac3cc4849289af4193726cc48890a6dd0ff Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 12 Nov 2025 16:16:15 +0200 Subject: [PATCH 2/7] Dynamic test CF name --- .../server/controller/CalculatedFieldControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index cf398bb693..85da8810bc 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -274,7 +274,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(entityId); calculatedField.setType(cfType); - calculatedField.setName("Test Calculated Field"); + calculatedField.setName("Test Calculated Field for " + entityId); calculatedField.setConfigurationVersion(1); if (customConfiguration != null) { calculatedField.setConfiguration(customConfiguration); From 524488bd3e19ec4f736b549b84ee07ce53b88e95 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 12 Nov 2025 16:41:52 +0200 Subject: [PATCH 3/7] Multiple names filter for CF --- .../controller/CalculatedFieldController.java | 10 +++++---- .../CalculatedFieldControllerTest.java | 21 +++++++++++++------ .../common/data/cf/CalculatedFieldFilter.java | 2 +- .../server/dao/service/DataValidator.java | 2 +- .../dao/sql/cf/CalculatedFieldRepository.java | 4 ++-- .../dao/sql/cf/JpaCalculatedFieldDao.java | 3 +-- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 46abed29c8..2d3c0dde5f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -25,6 +25,7 @@ import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -203,14 +204,15 @@ public class CalculatedFieldController extends BaseController { @RequestParam(required = false) EntityType entityType, @Parameter(description = "Entities filter. If not specified, calculated fields for entity type filter will be returned.") @RequestParam(required = false) List entities, - @Parameter(description = "Name filter.") - @RequestParam(required = false) String name, + @Parameter(description = "Name filter. To specify multiple names, duplicate 'name' parameter for each name, for example '?name=name1&name=name2") + @RequestParam(required = false) String name, // for Swagger only, retrieved from MultiValueMap params (due to issues when name contains comma) @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + @RequestParam(required = false) String sortOrder, + @RequestParam MultiValueMap params) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); SecurityUser user = getCurrentUser(); Set entityTypes; @@ -224,7 +226,7 @@ public class CalculatedFieldController extends BaseController { .type(type) .entityTypes(entityTypes) .entityIds(entities) - .name(name) + .names(params.get("name")) .build(); return calculatedFieldService.findCalculatedFieldsByTenantIdAndFilter(user.getTenantId(), filter, pageLink); } diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 85da8810bc..b472a0099a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -51,6 +51,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -223,28 +224,36 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { "Profile A" ); - List allCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, null); + List allCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + null, null, null); assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); - List profileLevelCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, EntityType.DEVICE_PROFILE, null, null); + List profileLevelCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + EntityType.DEVICE_PROFILE, null, null); assertThat(profileLevelCalculatedFields).containsOnly(profileCalculatedField); - List specificDeviceCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, EntityType.DEVICE, List.of(device.getUuidId()), null); + List specificDeviceCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + EntityType.DEVICE, List.of(device.getUuidId()), null); assertThat(specificDeviceCalculatedFields).containsOnly(deviceCalculatedField); - List byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, deviceCalculatedField.getName()); + List byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + null, null, List.of(deviceCalculatedField.getName())); assertThat(byNameCalculatedFields).containsOnly(deviceCalculatedField); + + byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, + List.of(deviceCalculatedField.getName(), profileCalculatedField.getName())); + assertThat(byNameCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); } private List getCalculatedFields(CalculatedFieldType type, EntityType entityType, List entities, - String name) throws Exception { + List names) throws Exception { return doGetTypedWithPageLink("/api/calculatedFields?type=" + type + "&" + (entityType != null ? "entityType=" + entityType + "&" : "") + (entities != null ? "entities=" + String.join(",", entities.stream().map(UUID::toString).toList()) + "&" : "") + - (name != null ? "name=" + name + "&" : ""), + (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), new TypeReference>() {}, new PageLink(10)).getData(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java index e99ceeeed9..40283cbaa3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java @@ -36,6 +36,6 @@ public class CalculatedFieldFilter { @Nullable private final List entityIds; @Nullable - private final String name; + private final List names; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java index 9f1f583108..76a7906b03 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java @@ -93,7 +93,7 @@ public abstract class DataValidator> { } public void validateString(String exceptionPrefix, String name) { - if (StringUtils.isEmpty(name) || name.trim().length() == 0) { + if (StringUtils.isBlank(name)) { throw new DataValidationException(exceptionPrefix + " should be specified!"); } if (StringUtils.contains0x00(name)) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index 31478666c2..1d214db7c1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -46,10 +46,10 @@ public interface CalculatedFieldRepository extends JpaRepository findByTenantIdAndFilter(UUID tenantId, String type, List entityTypes, - List entityIds, String name, String textSearch, Pageable pageable); + List entityIds, List names, String textSearch, Pageable pageable); List findAllByTenantId(UUID tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index a2a95bdf77..b8434913b4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.cf; -import com.google.common.base.Strings; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -110,7 +109,7 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao Date: Mon, 17 Nov 2025 13:42:38 +0200 Subject: [PATCH 4/7] Add API to get calculated field names --- .../controller/CalculatedFieldController.java | 18 +++++++++++++++ .../CalculatedFieldControllerTest.java | 22 +++++++++++++++++++ .../server/dao/cf/CalculatedFieldService.java | 2 ++ .../dao/cf/BaseCalculatedFieldService.java | 5 +++++ .../server/dao/cf/CalculatedFieldDao.java | 2 ++ .../dao/sql/cf/CalculatedFieldRepository.java | 5 +++++ .../dao/sql/cf/JpaCalculatedFieldDao.java | 7 ++++++ 7 files changed, 61 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 2d3c0dde5f..1c6b23084e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -231,6 +231,24 @@ public class CalculatedFieldController extends BaseController { return calculatedFieldService.findCalculatedFieldsByTenantIdAndFilter(user.getTenantId(), filter, pageLink); } + @ApiOperation(value = "Get calculated field names (getCalculatedFieldNames)", + notes = "Fetch the list of calculated field names for specified type.") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @GetMapping(value = "/calculatedFields/names") + public PageData getCalculatedFieldNames(@Parameter(description = "Calculated field type filter.") + @RequestParam CalculatedFieldType type, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, "name", sortOrder); + return calculatedFieldService.findCalculatedFieldNamesByTenantIdAndType(getTenantId(), type, pageLink); + } + @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)", notes = "Deletes the calculated field. Referencing non-existing Calculated Field Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index b472a0099a..3372b2cd95 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -42,12 +42,14 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupC import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; @@ -243,6 +245,26 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, List.of(deviceCalculatedField.getName(), profileCalculatedField.getName())); assertThat(byNameCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); + + PageData names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0, + null, new SortOrder("", SortOrder.Direction.ASC))); + assertThat(names.getTotalElements()).isEqualTo(2); + assertThat(names.getData()).isSortedAccordingTo(Comparator.naturalOrder()); + assertThat(names.getData()).contains(deviceCalculatedField.getName(), profileCalculatedField.getName()); + + names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0, + null, new SortOrder("", SortOrder.Direction.DESC))); + assertThat(names.getData()).isSortedAccordingTo(Comparator.reverseOrder()); + + names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0, + device.getId().toString(), new SortOrder("", SortOrder.Direction.DESC))); + assertThat(names.getTotalElements()).isEqualTo(1); + assertThat(names.getData()).containsOnly(deviceCalculatedField.getName()); + } + + private PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/calculatedFields/names?type=" + type + "&", + new TypeReference>() {}, pageLink); } private List getCalculatedFields(CalculatedFieldType type, diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java index 86bb877964..b33c8af33d 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -48,6 +48,8 @@ public interface CalculatedFieldService extends EntityDaoService { PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink); + PageData findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink); + PageData findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink); void deleteCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index b184c85cd4..55023288bf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -160,6 +160,11 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements }); } + @Override + public PageData findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink) { + return calculatedFieldDao.findNamesByTenantIdAndType(tenantId, type, pageLink); + } + @Override public PageData findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink) { log.trace("Executing findAllByEntityId, entityId [{}], pageLink [{}]", entityId, pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java index c93e770b8e..5a44824cc0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -52,4 +52,6 @@ public interface CalculatedFieldDao extends Dao { PageData findByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink); + PageData findNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index 1d214db7c1..d7bcbd0568 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -51,6 +51,11 @@ public interface CalculatedFieldRepository extends JpaRepository findByTenantIdAndFilter(UUID tenantId, String type, List entityTypes, List entityIds, List names, String textSearch, Pageable pageable); + @Query("SELECT DISTINCT cf.name FROM CalculatedFieldEntity cf " + + "WHERE cf.tenantId = :tenantId AND cf.type = :type AND " + + "(:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)") + Page findNamesByTenantIdAndType(UUID tenantId, String type,String textSearch, Pageable pageable); + List findAllByTenantId(UUID tenantId); List removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index b8434913b4..94aa11a920 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.cf; +import com.google.common.base.Strings; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -113,6 +114,12 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao findNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink) { + return DaoUtil.pageToPageData(calculatedFieldRepository.findNamesByTenantIdAndType(tenantId.getId(), type.name(), + Strings.emptyToNull(pageLink.getTextSearch()), DaoUtil.toPageable(pageLink, false))); + } + @Override protected Class getEntityClass() { return CalculatedFieldEntity.class; From dfab62dbda51f0c43aa9251167b341416237b0f7 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 18 Nov 2025 10:09:59 +0200 Subject: [PATCH 5/7] UI: Add alarm rule page --- .../core/http/calculated-fields.service.ts | 9 + ui-ngx/src/app/core/services/menu.models.ts | 32 +- .../alarm-rule-dialog.component.html | 30 +- .../alarm-rule-dialog.component.ts | 14 +- .../alarm-rule-filter-config.component.html | 94 ++++++ .../alarm-rule-filter-config.component.scss | 59 ++++ .../alarm-rule-filter-config.component.ts | 301 ++++++++++++++++++ .../alarm-rule-table-header.component.html | 20 ++ .../alarm-rule-table-header.component.scss | 21 ++ .../alarm-rule-table-header.component.ts | 44 +++ .../alarm-rules/alarm-rule.module.ts | 4 + .../alarm-rules/alarm-rules-table-config.ts | 50 ++- .../alarm-rules-table.component.html | 2 +- .../alarm-rules-table.component.scss | 2 +- .../alarm-rules-table.component.ts | 12 +- .../home/pages/alarm/alarm-routing.module.ts | 44 ++- .../entity/entity-subtype-list.component.ts | 23 +- .../app/shared/models/alarm-rule.models.ts | 7 + .../shared/models/calculated-field.models.ts | 7 + .../assets/locale/locale.constant-en_US.json | 15 +- 20 files changed, 763 insertions(+), 27 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts diff --git a/ui-ngx/src/app/core/http/calculated-fields.service.ts b/ui-ngx/src/app/core/http/calculated-fields.service.ts index 631550ae9e..e0f13636b1 100644 --- a/ui-ngx/src/app/core/http/calculated-fields.service.ts +++ b/ui-ngx/src/app/core/http/calculated-fields.service.ts @@ -21,6 +21,7 @@ import { HttpClient } from '@angular/common/http'; import { PageData } from '@shared/models/page/page-data'; import { CalculatedField, + CalculatedFieldsQuery, CalculatedFieldTestScriptInputParams, CalculatedFieldType } from '@shared/models/calculated-field.models'; @@ -50,6 +51,10 @@ export class CalculatedFieldsService { return this.http.delete(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config)); } + public getCalculatedFieldsFilter(pageLink: PageLink, query: CalculatedFieldsQuery, config?: RequestConfig): Observable> { + return this.http.get>(`/api/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(query, config)); + } + public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, type?: CalculatedFieldType, config?: RequestConfig): Observable> { return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(type ? {type} : null, config)); } @@ -61,4 +66,8 @@ export class CalculatedFieldsService { public getLatestCalculatedFieldDebugEvent(id: string, config?: RequestConfig): Observable { return this.http.get(`/api/calculatedField/${id}/debug`, defaultHttpOptionsFromConfig(config)); } + + public getAlarmRuleNames(pageLink: PageLink, type: CalculatedFieldType, config?: RequestConfig): Observable> { + return this.http.get>(`/api/calculatedFields/names${pageLink.toQuery()}`, createDefaultHttpOptions({type}, config)); + } } diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index 4277f96747..8909663771 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -85,7 +85,9 @@ export enum MenuId { domains = 'domains', clients = 'clients', audit_log = 'audit_log', + alarms_center = 'alarms_center', alarms = 'alarms', + alarm_rules = 'alarm_rules', dashboards = 'dashboards', entities = 'entities', devices = 'devices', @@ -494,16 +496,36 @@ export const menuSectionMap = new Map([ icon: 'track_changes' } ], + [ + MenuId.alarms_center, + { + id: MenuId.alarms_center, + name: 'alarm.alarms', + type: 'link', + path: '/alarms', + icon: 'mdi:alert-outline' + } + ], [ MenuId.alarms, { id: MenuId.alarms, name: 'alarm.alarms', type: 'link', - path: '/alarms', + path: '/alarms/alarms', icon: 'mdi:alert-outline' } ], + [ + MenuId.alarm_rules, + { + id: MenuId.alarm_rules, + name: 'alarm-rule.alarm-rules', + type: 'link', + path: '/alarms/alarm-rules', + icon: 'tune' + } + ], [ MenuId.dashboards, { @@ -792,7 +814,13 @@ const defaultUserMenuMap = new Map([ Authority.TENANT_ADMIN, [ {id: MenuId.home}, - {id: MenuId.alarms}, + { + id: MenuId.alarms_center, + pages: [ + {id: MenuId.alarms}, + {id: MenuId.alarm_rules} + ] + }, {id: MenuId.dashboards}, { id: MenuId.entities, 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 e0f1a052d5..d528ab3994 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 @@ -15,7 +15,7 @@ limitations under the License. --> -
+

{{ 'alarm-rule.alarm-rule' | translate}}

@@ -52,12 +52,38 @@ [additionalActionConfig]="additionalDebugActionConfig" />
+ @if (!data.entityId) { +
+ + alarm-rule.target-entity-type + + @for (type of alarmRuleEntityTypeList; track type) { + {{ entityTypeTranslations.get(type).type | translate }} + } + + + @if (fieldFormGroup.get('entityId.entityType').hasError('required')) { + {{ 'alarm-rule.entity-type-required' | translate }} + } + + @if (fieldFormGroup.get('entityId.entityType').value) { + + } +
+ }
{{ 'calculated-fields.arguments' | translate }}
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 0ea3bd036e..aa9e9223fb 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 @@ -23,7 +23,7 @@ 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 { EntityType } from '@shared/models/entity-type.models'; +import { AliasEntityType, 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'; @@ -57,13 +57,17 @@ export class AlarmRuleDialogComponent extends DialogComponent(null, Validators.required), + id: ['', Validators.required], + }), configuration: this.fb.group({ arguments: this.fb.control({}), propagate: [false], propagateToOwner: [false], propagateToTenant: [false], propagateRelationTypes: [null], - createRules: [null], + createRules: [null, Validators.required], clearRule: [null], }), }); @@ -74,6 +78,8 @@ export class AlarmRuleDialogComponent extends DialogComponent + + + + + + + + + +
+ +
+ + + + +
+
+
+ +
+
+
alarm-rule.alarm-rule-type-list
+ + +
+
+
alarm-rule.entity-type
+ + + {{ 'alarm-rule.any-type' | translate }} + + {{ entityTypeTranslations.get(type)?.type | translate }} + + + +
+ @if (alarmRuleFilterConfigForm.get('entityType').value) { +
+
alarm-rule.alarm-rule-entity-list
+ + +
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss new file mode 100644 index 0000000000..8c17ec5a29 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../scss/constants'; + +:host { + display: flex; + max-width: 100%; + .mdc-button { + max-width: 100%; + } +} + +:host ::ng-deep { + .mdc-button { + .mat-icon { + min-width: 24px; + } + .mdc-button__label { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +::ng-deep { + .tb-form-panel { + max-width: 100%; + width: 600px; + min-width: 100%; + } + + .tb-alarm-filter-config-component { + flex: 1; + + tb-entity-subtype-list { + flex: 1; + @media #{$mat-gt-xs} { + width: 180px; + } + .mdc-evolution-chip-set__chips { + width: 100%; + } + } + } +} 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 new file mode 100644 index 0000000000..2b8b2bee8f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts @@ -0,0 +1,301 @@ +/// +/// 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, + DestroyRef, + ElementRef, + forwardRef, + Inject, + InjectionToken, + Input, + OnDestroy, + OnInit, + Optional, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { TranslateService } from '@ngx-translate/core'; +import { deepClone, isArraysEqualIgnoreUndefined, isDefinedAndNotNull, isEmpty, isUndefinedOrNull } from '@core/utils'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { fromEvent, Subscription } from 'rxjs'; +import { POSITION_MAP } from '@shared/models/overlay.models'; +import { UtilsService } from '@core/services/utils.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AlarmRuleFilterConfig } from "@shared/models/alarm-rule.models"; + +export const ALARM_FILTER_CONFIG_DATA = new InjectionToken('AlarmRuleFilterConfigData'); + +export interface AlarmRuleFilterConfigData { + panelMode: boolean; + userMode: boolean; + alarmRuleFilterConfig: AlarmRuleFilterConfig; + initialAlarmRuleFilterConfig?: AlarmRuleFilterConfig; +} + +// @dynamic +@Component({ + selector: 'tb-alarm-rule-filter-config', + templateUrl: './alarm-rule-filter-config.component.html', + styleUrls: ['./alarm-rule-filter-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterConfigComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterConfigComponent implements OnInit, OnDestroy, ControlValueAccessor { + + @ViewChild('alarmRuleFilterPanel') + alarmRuleFilterPanel: TemplateRef; + + @Input() disabled: boolean; + + @coerceBoolean() + @Input() + buttonMode = true; + + @coerceBoolean() + @Input() + userMode = false; + + @coerceBoolean() + @Input() + propagatedFilter = true; + + @Input() + initialAlarmRuleFilterConfig: AlarmRuleFilterConfig = { + name: [], + entityType: null, + entities: [] + }; + + panelMode = false; + + buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter'); + + alarmRuleFilterConfigForm: FormGroup; + + alarmFilterOverlayRef: OverlayRef; + + panelResult: AlarmRuleFilterConfig = null; + + entityType = EntityType; + + listEntityTypes = [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE, EntityType.CUSTOMER]; + entityTypeTranslations = entityTypeTranslations; + + private alarmRuleFilterConfig: AlarmRuleFilterConfig; + private resizeWindows: Subscription; + + private propagateChange = (_: any) => {}; + + constructor(@Optional() @Inject(ALARM_FILTER_CONFIG_DATA) + private data: AlarmRuleFilterConfigData | undefined, + @Optional() + private overlayRef: OverlayRef, + private fb: FormBuilder, + private translate: TranslateService, + private overlay: Overlay, + private nativeElement: ElementRef, + private viewContainerRef: ViewContainerRef, + private utils: UtilsService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + if (this.data) { + this.panelMode = this.data.panelMode; + this.userMode = this.data.userMode; + this.alarmRuleFilterConfig = this.data.alarmRuleFilterConfig; + this.initialAlarmRuleFilterConfig = this.data.initialAlarmRuleFilterConfig; + if (this.panelMode && !this.initialAlarmRuleFilterConfig) { + this.initialAlarmRuleFilterConfig = deepClone(this.alarmRuleFilterConfig); + } + } + this.alarmRuleFilterConfigForm = this.fb.group({ + name: [null, []], + entityType: [null, []], + entities: [null, []] + }); + this.alarmRuleFilterConfigForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + if (!this.buttonMode) { + this.alarmRuleConfigUpdated(this.alarmRuleFilterConfigForm.value); + } + } + ); + if (this.panelMode) { + this.updateAlarmRuleConfigForm(this.alarmRuleFilterConfig); + } + } + + ngOnDestroy(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleFilterConfigForm.disable({emitEvent: false}); + } else { + this.alarmRuleFilterConfigForm.enable({emitEvent: false}); + } + } + + writeValue(alarmRuleFilterConfig?: AlarmRuleFilterConfig): void { + this.alarmRuleFilterConfig = alarmRuleFilterConfig; + if (!this.initialAlarmRuleFilterConfig && alarmRuleFilterConfig) { + this.initialAlarmRuleFilterConfig = deepClone(alarmRuleFilterConfig); + } + this.updateButtonDisplayValue(); + this.updateAlarmRuleConfigForm(alarmRuleFilterConfig); + } + + toggleAlarmRuleFilterPanel($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const config = new OverlayConfig({ + panelClass: 'tb-filter-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + maxHeight: '80vh', + height: 'min-content', + minWidth: '' + }); + config.hasBackdrop = true; + config.positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.nativeElement) + .withPositions([POSITION_MAP.bottomLeft]); + + this.alarmFilterOverlayRef = this.overlay.create(config); + this.alarmFilterOverlayRef.backdropClick().subscribe(() => { + this.alarmFilterOverlayRef.dispose(); + }); + this.alarmFilterOverlayRef.attach(new TemplatePortal(this.alarmRuleFilterPanel, + this.viewContainerRef)); + this.resizeWindows = fromEvent(window, 'resize').subscribe(() => { + this.alarmFilterOverlayRef.updatePosition(); + }); + } + + cancel() { + this.updateAlarmRuleConfigForm(this.alarmRuleFilterConfig); + if (this.overlayRef) { + this.overlayRef.dispose(); + } else { + this.resizeWindows.unsubscribe(); + this.alarmFilterOverlayRef.dispose(); + } + } + + update() { + this.alarmRuleConfigUpdated(this.alarmRuleFilterConfigForm.value); + this.alarmRuleFilterConfigForm.markAsPristine(); + if (this.panelMode) { + this.panelResult = this.alarmRuleFilterConfig; + } + if (this.overlayRef) { + this.overlayRef.dispose(); + } else { + this.resizeWindows.unsubscribe(); + this.alarmFilterOverlayRef.dispose(); + } + } + + reset() { + const alarmRuleFilterConfig = this.alarmRuleFilterConfigFromFormValue(this.alarmRuleFilterConfigForm.value); + if (!this.alarmRuleFilterConfigEquals(alarmRuleFilterConfig, this.initialAlarmRuleFilterConfig)) { + this.updateAlarmRuleConfigForm(this.initialAlarmRuleFilterConfig); + this.alarmRuleFilterConfigForm.markAsDirty(); + } + } + + private alarmRuleFilterConfigEquals = (filter1?: AlarmRuleFilterConfig, filter2?: AlarmRuleFilterConfig): boolean => { + if (filter1 === filter2) { + return true; + } + if ((isUndefinedOrNull(filter1) || isEmpty(filter1)) && (isUndefinedOrNull(filter2) || isEmpty(filter2))) { + return true; + } else if (isDefinedAndNotNull(filter1) && isDefinedAndNotNull(filter2)) { + if (!isArraysEqualIgnoreUndefined(filter1.name, filter2.name)) { + return false; + } + if (!isArraysEqualIgnoreUndefined(filter1.entities, filter2.entities)) { + return false; + } + return filter1.entityType !== filter2.entityType; + } + return false; + }; + + private updateAlarmRuleConfigForm(alarmRuleFilterConfig?: AlarmRuleFilterConfig) { + this.alarmRuleFilterConfigForm.patchValue({ + name: alarmRuleFilterConfig?.name ?? [], + entityType: alarmRuleFilterConfig?.entityType ?? null, + entities: alarmRuleFilterConfig?.entities ?? [], + }, {emitEvent: false}); + } + + private alarmRuleConfigUpdated(formValue: any) { + this.alarmRuleFilterConfig = this.alarmRuleFilterConfigFromFormValue(formValue); + this.updateButtonDisplayValue(); + this.propagateChange(this.alarmRuleFilterConfig); + } + + private alarmRuleFilterConfigFromFormValue(formValue: any): AlarmRuleFilterConfig { + return { + name: formValue?.name ?? [], + entityType: formValue?.entityType ?? null, + entities: formValue?.entities ?? [], + }; + } + + private updateButtonDisplayValue() { + if (this.buttonMode) { + const filterTextParts: string[] = []; + if (this.alarmRuleFilterConfig?.name?.length) { + filterTextParts.push(this.alarmRuleFilterConfig.name.map((type) => this.customTranslate(type)).join(', ')); + } + if (!filterTextParts.length) { + this.buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter-title'); + } else { + this.buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter-title') + `: ${filterTextParts.join(', ')}`; + } + } + } + + private customTranslate(entity: string) { + return this.utils.customTranslation(entity, entity); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html new file mode 100644 index 0000000000..807dd5c9f3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html @@ -0,0 +1,20 @@ + + + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss new file mode 100644 index 0000000000..ee0899b5ca --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss @@ -0,0 +1,21 @@ +/** + * 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 { + padding-right: 8px; + overflow: hidden; + max-width: 100%; +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts new file mode 100644 index 0000000000..33bf4f3f33 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts @@ -0,0 +1,44 @@ +/// +/// 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; +import { AlarmFilterConfig } from '@shared/models/query/query.models'; +import { CalculatedFieldAlarmRule } from "@shared/models/calculated-field.models"; +import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; + +@Component({ + selector: 'tb-alarm-rule-table-header', + templateUrl: './alarm-rule-table-header.component.html', + styleUrls: ['./alarm-rule-table-header.component.scss'] +}) +export class AlarmRuleTableHeaderComponent extends EntityTableHeaderComponent { + + get alarmRuleTableConfig(): AlarmRulesTableConfig { + return this.entitiesTableConfig as AlarmRulesTableConfig; + } + + constructor(protected store: Store) { + super(store); + } + + alarmRuleFilterChanged(alarmRuleFilterConfig: AlarmFilterConfig) { + this.alarmRuleTableConfig.alarmRuleFilterConfig = alarmRuleFilterConfig; + this.alarmRuleTableConfig.getTable().resetSortAndFilter(true, true); + } +} 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 7acfca7b0d..c02c11a170 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 @@ -48,6 +48,8 @@ import { import { AlarmRuleFilterListComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-list.component"; import { AlarmRuleFilterDialogComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; +import { AlarmRuleFilterConfigComponent } from "@home/components/alarm-rules/alarm-rule-filter-config.component"; +import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; @NgModule({ declarations: [ @@ -66,6 +68,8 @@ import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/al AlarmRuleFilterPredicateValueComponent, AlarmRuleComplexFilterPredicateDialogComponent, AlarmRuleDetailsDialogComponent, + AlarmRuleFilterConfigComponent, + AlarmRuleTableHeaderComponent ], imports: [ CommonModule, 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 5167e32853..968ab7fce7 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 @@ -17,10 +17,11 @@ import { checkBoxCell, DateEntityTableColumn, + EntityLinkTableColumn, EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { EntityType } from '@shared/models/entity-type.models'; +import { EntityType, 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'; @@ -55,6 +56,8 @@ import { } from "@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component"; import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.models"; import { UtilsService } from "@core/services/utils.service"; +import { deepClone, getEntityDetailsPageURL } from "@core/utils"; +import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; export class AlarmRulesTableConfig extends EntityTableConfig { @@ -64,6 +67,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig { action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField), }; + alarmRuleFilterConfig: any; + constructor(private calculatedFieldsService: CalculatedFieldsService, private translate: TranslateService, private dialog: MatDialog, @@ -77,11 +82,14 @@ export class AlarmRulesTableConfig extends EntityTableConfig { private importExportService: ImportExportService, private entityDebugSettingsService: EntityDebugSettingsService, private utilsService: UtilsService, + public pageMode: boolean = false, ) { super(); - this.tableTitle = this.translate.instant('alarm-rule.alarm-rules'); + if (this.pageMode) { + this.headerComponent = AlarmRuleTableHeaderComponent; + } + this.tableTitle = this.pageMode ? '' : this.translate.instant('alarm-rule.alarm-rules'); this.detailsPanelEnabled = false; - this.pageMode = false; this.entityType = EntityType.CALCULATED_FIELD; this.entityTranslations = { type: 'alarm-rule.alarm-rule', @@ -117,14 +125,29 @@ export class AlarmRulesTableConfig extends EntityTableConfig { this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); - this.columns.push(new EntityTableColumn('name', 'alarm-rule.alarm-type', '33%', + this.columns.push(new EntityTableColumn('name', 'alarm-rule.alarm-type', this.pageMode ? '30%' :'33%', entity => this.utilsService.customTranslation(entity.name, entity.name))); - this.columns.push(new EntityTableColumn('createRule', 'alarm-rule.severities', '67%', + if (this.pageMode) { + this.columns.push(new EntityTableColumn('entityType', 'alarm-rule.entity-type', '15%', + entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type))); + this.columns.push(new EntityLinkTableColumn('entityName', 'alarm-rule.entity-name', '30%', + entity => this.utilsService.customTranslation(entity['entityName'], entity['entityName']), + entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false)); + } + this.columns.push(new EntityTableColumn('createRule', 'alarm-rule.severities', this.pageMode ? '15%' :'67%', entity => Object.keys(entity.configuration.createRules).map((severity) => this.translate.instant(alarmSeverityTranslations.get(severity as AlarmSeverity))).join(', '), () => ({}), false)); this.columns.push(new EntityTableColumn('clearRule', 'alarm-rule.cleared', '70px', entity => checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: 0, textAlign: 'center'}}, false)); + this.cellActionDescriptors.push( + { + name: this.translate.instant('notification.copy-template'), + icon: 'content_copy', + isEnabled: () => true, + onAction: ($event, entity) => this.copyCalculatedField(entity) + } + ); this.cellActionDescriptors.push( { name: this.translate.instant('action.export'), @@ -156,7 +179,9 @@ export class AlarmRulesTableConfig extends EntityTableConfig { } fetchCalculatedFields(pageLink: PageLink): Observable> { - return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink, CalculatedFieldType.ALARM); + return this.pageMode ? + this.calculatedFieldsService.getCalculatedFieldsFilter(pageLink, {type: CalculatedFieldType.ALARM, ...this.alarmRuleFilterConfig}) : + this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink, CalculatedFieldType.ALARM); } onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void { @@ -195,6 +220,17 @@ export class AlarmRulesTableConfig extends EntityTableConfig { }); } + private copyCalculatedField(calculatedField: CalculatedField, isDirty = false): void { + const copyCalculatedAlarmRule = deepClone(calculatedField); + copyCalculatedAlarmRule.entityId = null; + this.getCalculatedAlarmDialog(calculatedField, 'action.apply', isDirty) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + private getCalculatedAlarmDialog(value?: CalculatedField, buttonTitle = 'action.add', isDirty = false): Observable { return this.dialog.open(AlarmRuleDialogComponent, { disableClose: true, @@ -205,7 +241,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig { entityId: this.entityId, tenantId: this.tenantId, entityName: this.entityName, - ownerId: this.ownerId, + ownerId: this.ownerId ?? {entityType: EntityType.TENANT, id: this.tenantId}, additionalDebugActionConfig: this.additionalDebugActionConfig, isDirty, }, diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html index e9eebb226c..fb0eab1447 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html @@ -16,5 +16,5 @@ --> @if (alarmRulesTableConfig) { - + } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss index 0e0dacc392..0884f69200 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss @@ -14,7 +14,7 @@ * limitations under the License. */ :host ::ng-deep { - tb-entities-table { + .white-background { --mat-sidenav-content-background-color: white; } } 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 4ade1c5461..de0dc87c12 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,6 +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"; @Component({ selector: 'tb-alarm-rules-table', @@ -55,6 +56,8 @@ export class AlarmRulesTableComponent { alarmRulesTableConfig: AlarmRulesTableConfig; + pageMode: boolean = false; + constructor(private calculatedFieldsService: CalculatedFieldsService, private translate: TranslateService, private dialog: MatDialog, @@ -65,10 +68,12 @@ export class AlarmRulesTableComponent { private importExportService: ImportExportService, private entityDebugSettingsService: EntityDebugSettingsService, private utilsService: UtilsService, - private destroyRef: DestroyRef) { - + private destroyRef: DestroyRef, + private route: ActivatedRoute, + ) { + this.pageMode = !!this.route.snapshot.data.isPage; effect(() => { - if (this.active()) { + if (this.active() || this.pageMode) { this.alarmRulesTableConfig = new AlarmRulesTableConfig( this.calculatedFieldsService, this.translate, @@ -83,6 +88,7 @@ export class AlarmRulesTableComponent { this.importExportService, this.entityDebugSettingsService, this.utilsService, + this.pageMode ); this.cd.markForCheck(); } 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 3cbe297d48..d6c5c4346a 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 @@ -22,6 +22,8 @@ import { OAuth2Service } from '@core/http/oauth2.service'; 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"; @Injectable() export class OAuth2LoginProcessingUrlResolver { @@ -37,16 +39,48 @@ export class OAuth2LoginProcessingUrlResolver { const routes: Routes = [ { path: 'alarms', - component: AlarmTableComponent, + component: RouterTabsComponent, data: { auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'alarm.alarms', breadcrumb: { menuId: MenuId.alarms + } + }, + children: [ + { + path: '', + children: [], + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + redirectTo: '/alarms/alarms' + } }, - isPage: true, - alarmsMode: AlarmsMode.ALL - } + { + path: 'alarms', + component: AlarmTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'alarm.alarms', + breadcrumb: { + menuId: MenuId.alarms + }, + isPage: true, + alarmsMode: AlarmsMode.ALL + } + }, + { + path: 'alarm-rules', + component: AlarmRulesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'alarm-rule.alarm-rules', + breadcrumb: { + menuId: MenuId.alarm_rules + }, + isPage: true, + } + } + ] } ]; diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts index 603f2c0e2b..6621dbd878 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts @@ -31,6 +31,8 @@ import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { UtilsService } from '@core/services/utils.service'; import { EntityService } from '@core/http/entity.service'; +import { CalculatedFieldType } from "@shared/models/calculated-field.models"; +import { CalculatedFieldsService } from "@core/http/calculated-fields.service"; @Component({ selector: 'tb-entity-subtype-list', @@ -77,6 +79,9 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, @Input() entityType: EntityType; + @Input() + calculatedFieldType: CalculatedFieldType; + @Input() emptyInputPlaceholder: string; @@ -117,12 +122,14 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, private propagateChange = (v: any) => { }; private hasPageDataEntitySubTypes = new Set([ - EntityType.ALARM + EntityType.ALARM, + EntityType.CALCULATED_FIELD ]); constructor(private broadcast: BroadcastService, public translate: TranslateService, private alarmService: AlarmService, + private calculatedFieldsService: CalculatedFieldsService, private utils: UtilsService, private fb: FormBuilder, private entityService: EntityService) { @@ -194,6 +201,13 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, this.noSubtypesMathingText = 'alarm.no-alarm-types-matching'; this.subtypeListEmptyText = 'alarm.alarm-type-list-empty'; break; + case EntityType.CALCULATED_FIELD: + this.placeholder = this.required ? this.translate.instant('alarm.enter-alarm-rule-type') + : this.translate.instant('alarm-rule.any-type'); + this.secondaryPlaceholder = '+' + this.translate.instant('alarm-rule.alarm-rule'); + this.noSubtypesMathingText = 'alarm-rule.no-alarm-rule-types-matching'; + this.subtypeListEmptyText = 'alarm-rule.alarm-rule-type-list-empty'; + break; } if (this.emptyInputPlaceholder) { @@ -315,15 +329,22 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, if (this.hasPageDataEntitySubTypes.has(this.entityType)) { const pageLink = new PageLink(25, 0, searchText); let subTypesPagesObservable: Observable>; + let subTypesCfPagesObservable: Observable>; switch (this.entityType) { case EntityType.ALARM: subTypesPagesObservable = this.alarmService.getAlarmTypes(pageLink, {ignoreLoading: true}); break; + case EntityType.CALCULATED_FIELD: + subTypesCfPagesObservable = this.calculatedFieldsService.getAlarmRuleNames(pageLink, CalculatedFieldType.ALARM, {ignoreLoading: true}); } if (subTypesPagesObservable) { this.entitySubtypes = subTypesPagesObservable.pipe( map(subTypesPage => subTypesPage.data.map(subType => subType.type)), ); + } else if (subTypesCfPagesObservable) { + this.entitySubtypes = subTypesCfPagesObservable.pipe( + map(subTypesPage => subTypesPage.data) + ); } else { return throwError(null); } 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 fafee4e97b..c9b549d78a 100644 --- a/ui-ngx/src/app/shared/models/alarm-rule.models.ts +++ b/ui-ngx/src/app/shared/models/alarm-rule.models.ts @@ -26,6 +26,7 @@ import { NumericOperation, StringOperation } from "@shared/models/query/query.models"; +import { EntityType } from "@shared/models/entity-type.models"; export enum AlarmRuleScheduleType { ANY_TIME = 'ANY_TIME', @@ -150,3 +151,9 @@ export interface BaseComplexFilterPredicate } export type ComplexAlarmRuleFilterPredicate = BaseComplexFilterPredicate; + +export interface AlarmRuleFilterConfig { + name?: Array; + entityType?: EntityType; + entities?: Array; +} 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 1b43700ebe..ad5aac81bf 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -911,3 +911,10 @@ export function notEmptyObjectValidator(): ValidatorFn { return null; }; } + +export interface CalculatedFieldsQuery { + type: CalculatedFieldType; + entityType?: EntityType; + entities?: Array; + name?: string; +} 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 a542ca33b6..52466ae6c0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1260,12 +1260,18 @@ "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 alarm rule} other {# alarm rules} }?", "delete-multiple-text": "Be careful, after the confirmation all selected alarm rules will be removed and all related data will become unrecoverable.", "create": "Create new alarm rule", + "add": "Add alarm rule", + "copy": "Copy alarm rule configuration", "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", "import": "Import alarm rule", "export": "Export alarm rule", "export-failed-error": "Unable to export alarm rule: {{error}}", + "entity-type": "Entity type", + "target-entity-type": "Target entity type", + "entity-type-required": "Entity type is required.", + "entity-name": "Entity name", "alarm-type": "Alarm type", "alarm-type-required": "Alarm type is required.", "alarm-type-pattern": "Alarm type is invalid.", @@ -1377,7 +1383,14 @@ "alarm-rule-relation-types-list-hint": "Defines relation types to filter the related entities. If not set, the alarm will be propagated to all related entities.", "propagate-alarm-to-owner": "Propagate alarm to entity owner (Customer or Tenant)", "propagate-alarm-to-tenant": "Propagate alarm to Tenant", - "debugging": "Alarm rule debugging" + "alarm-rule-filter-title": "Filter", + "debugging": "Alarm rule debugging", + "any-type": "Any type", + "enter-alarm-rule-type": "Enter alarm rule type", + "no-alarm-rule-types-matching": "No alarm rule types matching '{{entitySubtype}}' were found.", + "alarm-rule-type-list-empty": "No alarm rule types selected.", + "alarm-rule-type-list": "Alarm rule type list", + "alarm-rule-entity-list": "Entity list" }, "ai-models": { "ai-models": "AI models", From 5d6b35347fbbf57199deaedd13d4f310d9bf615b Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 18 Nov 2025 12:49:46 +0200 Subject: [PATCH 6/7] UI: Refactoring --- .../core/http/calculated-fields.service.ts | 13 ++++++++---- .../alarm-rule-dialog.component.html | 20 ++++++++----------- .../alarm-rule-filter-config.component.scss | 7 +++---- .../alarm-rule-filter-config.component.ts | 7 +------ .../alarm-rule-table-header.component.ts | 4 ++-- .../alarm-rules/alarm-rules-table-config.ts | 3 ++- .../entity/entity-type-select.component.html | 2 +- .../entity/entity-type-select.component.ts | 5 ++++- 8 files changed, 30 insertions(+), 31 deletions(-) diff --git a/ui-ngx/src/app/core/http/calculated-fields.service.ts b/ui-ngx/src/app/core/http/calculated-fields.service.ts index e0f13636b1..0fba704277 100644 --- a/ui-ngx/src/app/core/http/calculated-fields.service.ts +++ b/ui-ngx/src/app/core/http/calculated-fields.service.ts @@ -15,7 +15,12 @@ /// import { Injectable } from '@angular/core'; -import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { + createDefaultHttpOptions, + defaultHttpOptionsFromConfig, + defaultHttpOptionsFromParams, + RequestConfig +} from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageData } from '@shared/models/page/page-data'; @@ -52,11 +57,11 @@ export class CalculatedFieldsService { } public getCalculatedFieldsFilter(pageLink: PageLink, query: CalculatedFieldsQuery, config?: RequestConfig): Observable> { - return this.http.get>(`/api/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(query, config)); + return this.http.get>(`/api/calculatedFields${pageLink.toQuery()}`, defaultHttpOptionsFromParams(query, config)); } public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, type?: CalculatedFieldType, config?: RequestConfig): Observable> { - return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(type ? {type} : null, config)); + return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, defaultHttpOptionsFromParams({type} , config)); } public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable { @@ -68,6 +73,6 @@ export class CalculatedFieldsService { } public getAlarmRuleNames(pageLink: PageLink, type: CalculatedFieldType, config?: RequestConfig): Observable> { - return this.http.get>(`/api/calculatedFields/names${pageLink.toQuery()}`, createDefaultHttpOptions({type}, config)); + return this.http.get>(`/api/calculatedFields/names${pageLink.toQuery()}`, defaultHttpOptionsFromParams({type}, config)); } } 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 d528ab3994..b023853faf 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 @@ -54,18 +54,14 @@
@if (!data.entityId) {
- - alarm-rule.target-entity-type - - @for (type of alarmRuleEntityTypeList; track type) { - {{ entityTypeTranslations.get(type).type | translate }} - } - - - @if (fieldFormGroup.get('entityId.entityType').hasError('required')) { - {{ 'alarm-rule.entity-type-required' | translate }} - } - + + @if (fieldFormGroup.get('entityId.entityType').value) { ; @@ -152,9 +150,6 @@ export class AlarmRuleFilterConfigComponent implements OnInit, OnDestroy, Contro } } - ngOnDestroy(): void { - } - registerOnChange(fn: any): void { this.propagateChange = fn; } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts index 33bf4f3f33..cf5462d1a2 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts @@ -19,7 +19,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; import { AlarmFilterConfig } from '@shared/models/query/query.models'; -import { CalculatedFieldAlarmRule } from "@shared/models/calculated-field.models"; +import { CalculatedFieldAlarmRule, CalculatedFieldsQuery } from "@shared/models/calculated-field.models"; import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; @Component({ @@ -37,7 +37,7 @@ export class AlarmRuleTableHeaderComponent extends EntityTableHeaderComponent { action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField), }; - alarmRuleFilterConfig: any; + alarmRuleFilterConfig: CalculatedFieldsQuery; constructor(private calculatedFieldsService: CalculatedFieldsService, private translate: TranslateService, diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index 6506d233b0..cf93e40f50 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -16,7 +16,7 @@ --> {{ label }} 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 f8a4f49a82..9e9ffcb474 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 @@ -21,7 +21,7 @@ import { AliasEntityType, EntityType, entityTypeTranslations } from '@app/shared import { EntityService } from '@core/http/entity.service'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatFormFieldAppearance } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-type-select', @@ -68,6 +68,9 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + @Input() @coerceBoolean() inlineField: boolean; From c10aff3910b140e8dddf141b0421ceab10f6c1e3 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 19 Nov 2025 10:31:34 +0200 Subject: [PATCH 7/7] UI: bug fixes with alarm rule --- ui-ngx/src/app/core/http/entity.service.ts | 6 ++ .../alarm-rule-dialog.component.html | 5 +- .../alarm-rule-dialog.component.ts | 4 +- .../alarm-rule-filter-config.component.ts | 2 +- ...alarm-rule-condition-dialog.component.html | 5 +- .../cf-alarm-rule-condition.component.ts | 16 +++-- .../alarm-rules/cf-alarm-rule.component.html | 2 +- .../alarm-rules/cf-alarm-rule.component.ts | 21 +++--- .../cf-alarm-rules-dialog.component.scss | 65 +++++++++---------- .../create-cf-alarm-rules.component.html | 1 + .../create-cf-alarm-rules.component.ts | 2 +- ...culated-field-arguments-table.component.ts | 2 +- .../entity-debug-settings-button.component.ts | 2 + .../add-device-profile-dialog.component.html | 11 ---- .../add-device-profile-dialog.component.ts | 11 ---- .../entity/entity-autocomplete.component.ts | 12 ++++ .../assets/locale/locale.constant-en_US.json | 6 +- 17 files changed, 89 insertions(+), 84 deletions(-) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 5cd4167afa..4daae2115d 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -189,6 +189,12 @@ export class EntityService { case EntityType.AI_MODEL: observable = this.aiModelService.getAiModelById(entityId, config); break; + case EntityType.DEVICE_PROFILE: + observable = this.deviceProfileService.getDeviceProfile(entityId, config); + break; + case EntityType.ASSET_PROFILE: + observable = this.assetProfileService.getAssetProfile(entityId, config); + break; } return observable; } 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 b023853faf..d4a93d461f 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 @@ -47,6 +47,7 @@
-
{{ 'alarm-rule.create-alarm-rules' | translate }}
+
{{ 'alarm-rule.create-conditions' | translate }}
-
{{ 'alarm-rule.clear-alarm-rule' | translate }}
+
{{ 'alarm-rule.clear-condition' | translate }}
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 aa9e9223fb..b3176414ab 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 @@ -79,7 +79,7 @@ export class AlarmRuleDialogComponent extends DialogComponent {{ 'alarm-rule.expression-type.simple' | translate }} - {{ 'alarm-rule.expression-type.tbel' | translate }} + {{ 'alarm-rule.expression-type.script' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts index 17ed11d892..2e8d116ad2 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -74,12 +74,16 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali @coerceBoolean() disabled: boolean; + @Input() + @coerceBoolean() + required: boolean; + @Input() arguments: Record; alarmRuleConditionFormGroup = this.fb.group({ type: ['SIMPLE'], - expression: [{type: AlarmRuleExpressionType.SIMPLE}, Validators.required], + expression: [{type: AlarmRuleExpressionType.SIMPLE}], schedule: [null], }); @@ -114,19 +118,21 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali } writeValue(value: AlarmRuleCondition): void { - this.modelValue = value; - this.updateConditionInfo(); + if (value) { + this.modelValue = value; + this.updateConditionInfo(); + } } public conditionSet() { - return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters); + return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters) || !this.required; } public validate(c: UntypedFormControl) { return this.conditionSet() ? null : { alarmRuleCondition: { valid: false, - }, + } }; } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html index cd6b2c3796..14a88b2bf1 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -16,7 +16,7 @@ -->
- + @if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) {
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts index 88ae1dd79d..f5e57310a2 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -21,14 +21,13 @@ import { NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormControl, - Validator, - Validators + Validator } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { isDefinedAndNotNull } from '@core/utils'; import { DashboardId } from '@shared/models/id/dashboard-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { AlarmRule } from "@shared/models/alarm-rule.models"; +import { AlarmRule, AlarmRuleCondition } from "@shared/models/alarm-rule.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmRuleDetailsDialogComponent, @@ -69,7 +68,7 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid private modelValue: AlarmRule; alarmRuleFormGroup = this.fb.group({ - condition: this.fb.control({}, Validators.required), + condition: this.fb.control(null), alarmDetails: [null], dashboardId: [null] }); @@ -106,12 +105,14 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid } writeValue(value: AlarmRule): void { - this.modelValue = value; - const model = this.modelValue ? { - ...this.modelValue, - dashboardId: this.modelValue.dashboardId?.id - } : null; - this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); + if (value) { + this.modelValue = value; + const model = this.modelValue ? { + ...this.modelValue, + dashboardId: this.modelValue.dashboardId?.id + } : null; + this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); + } } public openEditDetailsDialog($event: Event) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss index 14df9a3972..b66573f417 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss @@ -20,41 +20,38 @@ display: grid; grid-template-rows: min-content minmax(auto, 1fr) min-content; } - - .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; - } +} +.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/alarm-rules/create-cf-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html index 820418a6b0..b0e6164d30 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html @@ -55,6 +55,7 @@