Browse Source

Merge pull request #14328 from thingsboard/feature/alarm-rules-page

Tenant alarm rules page
pull/14369/head
Viacheslav Klimov 10 months ago
committed by GitHub
parent
commit
428299081a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 117
      application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
  2. 2
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  3. 84
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  4. 6
      common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java
  5. 41
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldFilter.java
  6. 34
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldInfo.java
  7. 25
      dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java
  8. 5
      dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java
  9. 2
      dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java
  10. 14
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java
  11. 18
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java
  12. 18
      ui-ngx/src/app/core/http/calculated-fields.service.ts
  13. 6
      ui-ngx/src/app/core/http/entity.service.ts
  14. 32
      ui-ngx/src/app/core/services/menu.models.ts
  15. 31
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html
  16. 14
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts
  17. 94
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html
  18. 58
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss
  19. 296
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts
  20. 20
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html
  21. 21
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss
  22. 44
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts
  23. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts
  24. 51
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  25. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html
  26. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss
  27. 12
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts
  28. 5
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html
  29. 16
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts
  30. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html
  31. 21
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts
  32. 65
      ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss
  33. 1
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html
  34. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts
  35. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts
  36. 2
      ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts
  37. 11
      ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html
  38. 11
      ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts
  39. 44
      ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts
  40. 12
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  41. 23
      ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts
  42. 2
      ui-ngx/src/app/shared/components/entity/entity-type-select.component.html
  43. 5
      ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts
  44. 7
      ui-ngx/src/app/shared/models/alarm-rule.models.ts
  45. 7
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  46. 21
      ui-ngx/src/assets/locale/locale.constant-en_US.json

117
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;
@ -44,6 +45,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 +62,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 +106,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 +190,65 @@ 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<CalculatedFieldInfo> 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<UUID> entities,
@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,
@RequestParam MultiValueMap<String, String> params) throws ThingsboardException {
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
SecurityUser user = getCurrentUser();
Set<EntityType> entityTypes;
if (entityType == null) {
entityTypes = CalculatedField.SUPPORTED_ENTITIES.keySet();
} else {
entityTypes = EnumSet.of(entityType);
}
CalculatedFieldFilter filter = CalculatedFieldFilter.builder()
.type(type)
.entityTypes(entityTypes)
.entityIds(entities)
.names(params.get("name"))
.build();
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<String> 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')")
@ -295,7 +361,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.");
}
}
}

2
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -1421,7 +1421,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return doPost("/api/calculatedField", calculatedField, CalculatedField.class);
}
protected PageData<CalculatedField> getCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception {
protected PageData<CalculatedField> 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);
}

84
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;
@ -40,16 +44,21 @@ 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.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;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
@ -225,17 +234,84 @@ 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<CalculatedFieldInfo> allCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
null, null, null);
assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField);
List<CalculatedFieldInfo> profileLevelCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
EntityType.DEVICE_PROFILE, null, null);
assertThat(profileLevelCalculatedFields).containsOnly(profileCalculatedField);
List<CalculatedFieldInfo> specificDeviceCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE,
EntityType.DEVICE, List.of(device.getUuidId()), null);
assertThat(specificDeviceCalculatedFields).containsOnly(deviceCalculatedField);
List<CalculatedFieldInfo> 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);
PageData<String> 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<String> getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception {
return doGetTypedWithPageLink("/api/calculatedFields/names?type=" + type + "&",
new TypeReference<PageData<String>>() {}, pageLink);
}
private List<CalculatedFieldInfo> getCalculatedFields(CalculatedFieldType type,
EntityType entityType,
List<UUID> entities,
List<String> names) throws Exception {
return doGetTypedWithPageLink("/api/calculatedFields?type=" + type + "&" +
(entityType != null ? "entityType=" + entityType + "&" : "") +
(entities != null ? "entities=" + String.join(",",
entities.stream().map(UUID::toString).toList()) + "&" : "") +
(names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""),
new TypeReference<PageData<CalculatedFieldInfo>>() {}, new PageLink(10)).getData();
}
@Test
public void testDeleteCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
@ -262,7 +338,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);

6
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,10 @@ public interface CalculatedFieldService extends EntityDaoService {
PageData<CalculatedField> findCalculatedFieldsByTenantId(TenantId tenantId, PageLink pageLink);
PageData<CalculatedFieldInfo> findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink);
PageData<String> findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink);
PageData<CalculatedField> findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink);
void deleteCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId);

41
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<EntityType> entityTypes;
@Nullable
private final List<UUID> entityIds;
@Nullable
private final List<String> names;
}

34
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;
}
}

25
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,24 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements
return calculatedFieldDao.findAllByTenantId(tenantId, pageLink);
}
@Override
public PageData<CalculatedFieldInfo> findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink) {
PageData<CalculatedField> calculatedFields = calculatedFieldDao.findByTenantIdAndFilter(tenantId, filter, pageLink);
Set<EntityId> entityIds = calculatedFields.getData().stream()
.map(CalculatedField::getEntityId)
.collect(Collectors.toSet());
Map<EntityId, EntityInfo> 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<String> findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink) {
return calculatedFieldDao.findNamesByTenantIdAndType(tenantId, type, pageLink);
}
@Override
public PageData<CalculatedField> findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink) {
log.trace("Executing findAllByEntityId, entityId [{}], pageLink [{}]", entityId, pageLink);

5
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,8 @@ public interface CalculatedFieldDao extends Dao<CalculatedField> {
long countByEntityIdAndTypeNot(TenantId tenantId, EntityId entityId, CalculatedFieldType type);
PageData<CalculatedField> findByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink);
PageData<String> findNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink);
}

2
dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java

@ -93,7 +93,7 @@ public abstract class DataValidator<D extends BaseData<?>> {
}
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)) {

14
dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java

@ -42,6 +42,20 @@ public interface CalculatedFieldRepository extends JpaRepository<CalculatedField
"AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)")
Page<CalculatedFieldEntity> findByTenantIdAndEntityIdAndTypes(UUID tenantId, UUID entityId, List<String> 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 (:names IS NULL OR cf.name IN :names) " +
"AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)")
Page<CalculatedFieldEntity> findByTenantIdAndFilter(UUID tenantId, String type, List<String> entityTypes,
List<UUID> entityIds, List<String> 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<String> findNamesByTenantIdAndType(UUID tenantId, String type,String textSearch, Pageable pageable);
List<CalculatedFieldEntity> findAllByTenantId(UUID tenantId);
List<CalculatedFieldEntity> removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId);

18
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,21 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao<CalculatedFieldEntity,
return calculatedFieldRepository.countByTenantIdAndEntityIdAndTypeNot(tenantId.getId(), entityId.getId(), type.name());
}
@Override
public PageData<CalculatedField> 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,
CollectionUtils.isNotEmpty(filter.getNames()) ? filter.getNames() : null,
pageLink.getTextSearch(), DaoUtil.toPageable(pageLink)));
}
@Override
public PageData<String> 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<CalculatedFieldEntity> getEntityClass() {
return CalculatedFieldEntity.class;

18
ui-ngx/src/app/core/http/calculated-fields.service.ts

@ -15,12 +15,18 @@
///
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';
import {
CalculatedField,
CalculatedFieldsQuery,
CalculatedFieldTestScriptInputParams,
CalculatedFieldType
} from '@shared/models/calculated-field.models';
@ -50,8 +56,12 @@ export class CalculatedFieldsService {
return this.http.delete<boolean>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
}
public getCalculatedFieldsFilter(pageLink: PageLink, query: CalculatedFieldsQuery, config?: RequestConfig): Observable<PageData<CalculatedField>> {
return this.http.get<PageData<CalculatedField>>(`/api/calculatedFields${pageLink.toQuery()}`, defaultHttpOptionsFromParams(query, config));
}
public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, type?: CalculatedFieldType, config?: RequestConfig): Observable<PageData<CalculatedField>> {
return this.http.get<PageData<CalculatedField>>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(type ? {type} : null, config));
return this.http.get<PageData<CalculatedField>>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, defaultHttpOptionsFromParams({type} , config));
}
public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable<EntityTestScriptResult> {
@ -61,4 +71,8 @@ export class CalculatedFieldsService {
public getLatestCalculatedFieldDebugEvent(id: string, config?: RequestConfig): Observable<CalculatedFieldEventBody> {
return this.http.get<CalculatedFieldEventBody>(`/api/calculatedField/${id}/debug`, defaultHttpOptionsFromConfig(config));
}
public getAlarmRuleNames(pageLink: PageLink, type: CalculatedFieldType, config?: RequestConfig): Observable<PageData<string>> {
return this.http.get<PageData<string>>(`/api/calculatedFields/names${pageLink.toQuery()}`, defaultHttpOptionsFromParams({type}, config));
}
}

6
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;
}

32
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<MenuId, MenuSection>([
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, MenuReference[]>([
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,

31
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div [formGroup]="fieldFormGroup" class="calculated-field-dialog-container flex size-full max-w-4xl flex-col">
<div [formGroup]="fieldFormGroup" class="calculated-field-dialog-container flex h-full flex-col">
<mat-toolbar color="primary">
<h2>{{ 'alarm-rule.alarm-rule' | translate}}</h2>
<span class="flex-1"></span>
@ -47,30 +47,53 @@
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
entityLabel="alarm-rule.alarm-rule"
[class.mb-5]="fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched"
[entityType]="EntityType.CALCULATED_FIELD"
[additionalActionConfig]="additionalDebugActionConfig"
/>
</div>
@if (!data.entityId) {
<div class="flex flex-col gap-4" formGroupName="entityId">
<tb-entity-type-select appearance="outline"
subscriptSizing="dynamic"
showLabel
label="{{ 'alarm-rule.target-entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="alarmRuleEntityTypeList"
formControlName="entityType">
</tb-entity-type-select>
@if (fieldFormGroup.get('entityId.entityType').value) {
<tb-entity-autocomplete
formControlName="id"
appearance="outline"
subscriptSizing="dynamic"
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="fieldFormGroup.get('entityId.entityType').value"
/>
}
</div>
}
</div>
<ng-container formGroupName="configuration">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="data.entityId"
[entityId]="data.entityId || fieldFormGroup.get('entityId').value"
[tenantId]="data.tenantId"
[ownerId]="data.ownerId"
[entityName]="data.entityName"/>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.create-alarm-rules' | translate }}</div>
<div class="tb-form-panel-title">{{ 'alarm-rule.create-conditions' | translate }}</div>
<div class="flex flex-1 flex-col">
<tb-create-cf-alarm-rules formControlName="createRules" [arguments]="arguments">
</tb-create-cf-alarm-rules>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'alarm-rule.clear-alarm-rule' | translate }}</div>
<div class="tb-form-panel-title">{{ 'alarm-rule.clear-condition' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-2 pb-2"
[class.!hidden]="!configFormGroup.get('clearRule').value">
<div class="clear-alarm-rule flex flex-1 flex-row">

14
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<AlarmRuleDialogCom
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.ALARM],
debugSettings: [],
entityId: this.fb.group({
entityType: this.fb.control<EntityType | AliasEntityType | null>(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<AlarmRuleDialogCom
} : null;
readonly EntityType = EntityType;
readonly entityTypeTranslations = entityTypeTranslations;
readonly alarmRuleEntityTypeList = [EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE];
readonly CalculatedFieldType = CalculatedFieldType;
readonly ScriptLanguage = ScriptLanguage;
@ -160,8 +166,8 @@ export class AlarmRuleDialogComponent extends DialogComponent<AlarmRuleDialogCom
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false});
const { configuration = {}, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.data.entityId, ...value } = this.data.value ?? {};
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, entityId, ...value }, {emitEvent: false});
}
private observeIsLoading(): void {

94
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html

@ -0,0 +1,94 @@
<!--
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.
-->
<ng-container *ngIf="panelMode; else componentMode">
<ng-container *ngTemplateOutlet="alarmRuleFilterPanel"></ng-container>
</ng-container>
<ng-template #componentMode>
<ng-container *ngIf="buttonMode; else alarmFilter">
<button color="primary"
matTooltip="{{buttonDisplayValue}}"
matTooltipPosition="above"
mat-stroked-button
(click)="toggleAlarmRuleFilterPanel($event)">
<mat-icon>filter_list</mat-icon>{{buttonDisplayValue}}
</button>
</ng-container>
</ng-template>
<ng-template #alarmRuleFilterPanel>
<form class="mat-content mat-padding flex flex-col" (ngSubmit)="update()">
<ng-container *ngTemplateOutlet="alarmFilter"></ng-container>
<div class="tb-panel-actions flex flex-row items-center justify-end">
<button type="button"
mat-button
color="primary"
(click)="reset()">
{{ 'action.reset' | translate }}
</button>
<span class="flex-1"></span>
<button type="button"
mat-button
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button type="submit"
mat-raised-button
color="primary"
[disabled]="alarmRuleFilterConfigForm.invalid || !alarmRuleFilterConfigForm.dirty">
{{ 'action.update' | translate }}
</button>
</div>
</form>
</ng-template>
<ng-template #alarmFilter>
<div class="tb-form-panel tb-alarm-filter-config-component no-padding no-border" [formGroup]="alarmRuleFilterConfigForm">
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>alarm-rule.alarm-rule-type-list</div>
<tb-entity-subtype-list subscriptSizing="dynamic"
formControlName="name"
appearance="outline"
[additionalClasses]="['tb-chips', 'flex']"
[entityType]="entityType.CALCULATED_FIELD">
</tb-entity-subtype-list>
</div>
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>alarm-rule.entity-type</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType" placeholder="{{ 'alarm-rule.any-type' | translate }}">
<mat-option>{{ 'alarm-rule.any-type' | translate }}</mat-option>
<mat-option *ngFor="let type of listEntityTypes" [value]="type">
{{ entityTypeTranslations.get(type)?.type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
@if (alarmRuleFilterConfigForm.get('entityType').value) {
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>alarm-rule.alarm-rule-entity-list</div>
<tb-entity-list appearance="outline"
subscriptSizing="dynamic"
class="flex flex-1"
inlineField
syncIdsWithDB
labelText="{{'entity.entity-list' | translate}}"
[entityType]="alarmRuleFilterConfigForm.get('entityType').value"
formControlName="entities">
</tb-entity-list>
</div>
}
</div>
</ng-template>

58
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@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-alarm-filter-config-component {
max-width: 100%;
width: 600px;
min-width: 100%;
flex: 1;
tb-entity-subtype-list {
flex: 1;
@media #{$mat-gt-xs} {
width: 180px;
}
.mdc-evolution-chip-set__chips {
width: 100%;
}
}
}
}

296
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts

@ -0,0 +1,296 @@
///
/// 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,
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<any>('AlarmRuleFilterConfigData');
export interface AlarmRuleFilterConfigData {
panelMode: boolean;
userMode: boolean;
alarmRuleFilterConfig: AlarmRuleFilterConfig;
initialAlarmRuleFilterConfig?: AlarmRuleFilterConfig;
}
@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, ControlValueAccessor {
@ViewChild('alarmRuleFilterPanel')
alarmRuleFilterPanel: TemplateRef<any>;
@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.CUSTOMER, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE];
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);
}
}
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);
}
}

20
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html

@ -0,0 +1,20 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<tb-alarm-rule-filter-config [ngModel]="alarmRuleTableConfig.alarmRuleFilterConfig"
(ngModelChange)="alarmRuleFilterChanged($event)">
</tb-alarm-rule-filter-config>

21
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%;
}

44
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, CalculatedFieldsQuery } 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<CalculatedFieldAlarmRule> {
get alarmRuleTableConfig(): AlarmRulesTableConfig {
return this.entitiesTableConfig as AlarmRulesTableConfig;
}
constructor(protected store: Store<AppState>) {
super(store);
}
alarmRuleFilterChanged(alarmRuleFilterConfig: CalculatedFieldsQuery) {
this.alarmRuleTableConfig.alarmRuleFilterConfig = alarmRuleFilterConfig;
this.alarmRuleTableConfig.getTable().resetSortAndFilter(true, true);
}
}

4
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,

51
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';
@ -40,6 +41,7 @@ import {
ArgumentEntityType,
CalculatedField,
CalculatedFieldAlarmRule,
CalculatedFieldsQuery,
CalculatedFieldType,
} from '@shared/models/calculated-field.models';
import { ImportExportService } from '@shared/import-export/import-export.service';
@ -55,6 +57,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<any> {
@ -64,6 +68,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField),
};
alarmRuleFilterConfig: CalculatedFieldsQuery;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private translate: TranslateService,
private dialog: MatDialog,
@ -77,11 +83,14 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
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 +126,29 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.columns.push(new DateEntityTableColumn<CalculatedFieldAlarmRule>('createdTime', 'common.created-time', this.datePipe, '150px'));
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('name', 'alarm-rule.alarm-type', '33%',
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('name', 'alarm-rule.alarm-type', this.pageMode ? '30%' :'33%',
entity => this.utilsService.customTranslation(entity.name, entity.name)));
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('createRule', 'alarm-rule.severities', '67%',
if (this.pageMode) {
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('entityType', 'alarm-rule.entity-type', '15%',
entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type)));
this.columns.push(new EntityLinkTableColumn<CalculatedFieldAlarmRule>('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<CalculatedFieldAlarmRule>('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<CalculatedFieldAlarmRule>('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 +180,9 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
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 +221,17 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
});
}
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<CalculatedField> {
return this.dialog.open<AlarmRuleDialogComponent, AlarmRuleDialogData, CalculatedField>(AlarmRuleDialogComponent, {
disableClose: true,
@ -205,7 +242,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
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,
},

2
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html

@ -16,5 +16,5 @@
-->
@if (alarmRulesTableConfig) {
<tb-entities-table [entitiesTableConfig]="alarmRulesTableConfig"></tb-entities-table>
<tb-entities-table [class.white-background]="!pageMode" [entitiesTableConfig]="alarmRulesTableConfig"></tb-entities-table>
}

2
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;
}
}

12
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();
}

5
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html

@ -22,7 +22,7 @@
<div formGroupName="expression" class="flex flex-row items-center justify-start gt-xs:gap-4" style="min-width: fit-content;">
<tb-toggle-select formControlName="type" appearance="fill-invert" selectMediaBreakpoint="xs">
<tb-toggle-option [value]="AlarmRuleExpressionType.SIMPLE">{{ 'alarm-rule.expression-type.simple' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="AlarmRuleExpressionType.TBEL">{{ 'alarm-rule.expression-type.tbel' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="AlarmRuleExpressionType.TBEL">{{ 'alarm-rule.expression-type.script' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<button mat-icon-button
@ -57,6 +57,7 @@
</div>
<tb-js-func formControlName="expression"
functionName="expression"
class="tb-alarm-rule-script"
[disableUndefinedCheck]="true"
[scriptLanguage]="scriptLanguage.TBEL"
[functionArgs]="functionArgs"
@ -64,7 +65,7 @@
[editorCompleter]="argumentsEditorCompleter"
noValidate="true">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'alarm-rule.expression-type.tbel' | translate }}
class="tb-primary-background tbel-script-lang-chip">{{ 'alarm-rule.expression-type.script' | translate }}
</div>
</tb-js-func>
</div>

16
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<string, CalculatedFieldArgument>;
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,
},
}
};
}

2
ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html

@ -16,7 +16,7 @@
-->
<div class="tb-form-panel no-border no-padding" [formGroup]="alarmRuleFormGroup">
<tb-cf-alarm-rule-condition formControlName="condition" [arguments]="arguments">
<tb-cf-alarm-rule-condition formControlName="condition" [arguments]="arguments" [required]="required">
</tb-cf-alarm-rule-condition>
@if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) {
<div class="tb-form-row space-between column-xs">

21
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<AlarmRuleCondition | null>(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) {

65
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;
}
}
}
}

1
ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html

@ -55,6 +55,7 @@
</div>
<div *ngIf="!disabled">
<button mat-stroked-button color="primary"
[disabled]="createAlarmRulesFormArray().controls.length > 4"
type="button"
(click)="addCreateAlarmRule()"
matTooltip="{{ 'alarm-rule.add-create-alarm-rule' | translate }}"

2
ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts

@ -157,7 +157,7 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida
}
public validate(c: UntypedFormControl) {
return (this.createAlarmRulesFormGroup.valid) ? null : {
return (this.createAlarmRulesFormArray().length && this.createAlarmRulesFormGroup.valid) ? null : {
createAlarmRules: {
valid: false,
},

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts

@ -271,7 +271,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
return acc;
}, {} as Record<EntityType, string[]>);
const tasks = Object.entries(entitiesByType).map(([entityType, ids]) =>
this.entityService.getEntities(entityType as EntityType, ids)
this.entityService.getEntities(entityType as EntityType, ids, {ignoreLoading: true})
);
if (!tasks.length) {
return;

2
ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts

@ -61,6 +61,7 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor
@Input() additionalActionConfig: AdditionalDebugActionConfig;
@Input({required: true}) entityType: EntityType;
@Input() entityLabel: string;
debugSettingsFormGroup = this.fb.group({
failuresEnabled: [false],
@ -124,6 +125,7 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor
debugConfig: {
maxDebugModeDuration: this.maxDebugModeDuration,
entityType: this.entityType,
entityLabel: this.entityLabel,
additionalActionConfig: this.additionalActionConfig,
},
onSettingsAppliedFn: settings => {

11
ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html

@ -112,17 +112,6 @@
</tb-device-profile-transport-configuration>
</form>
</mat-step>
<mat-step [stepControl]="alarmRulesFormGroup" [optional]="true">
<form [formGroup]="alarmRulesFormGroup" style="padding-bottom: 16px;">
<ng-template matStepLabel>{{'device-profile.alarm-rules-with-count' | translate:
{count: alarmRulesFormGroup.get('alarms').value ?
alarmRulesFormGroup.get('alarms').value.length : 0} }}</ng-template>
<tb-device-profile-alarms
formControlName="alarms"
[deviceProfileId]="null">
</tb-device-profile-alarms>
</form>
</mat-step>
<mat-step [stepControl]="provisionConfigFormGroup" [optional]="true">
<form [formGroup]="provisionConfigFormGroup" style="padding-bottom: 16px;">
<ng-template matStepLabel>{{ 'device-profile.device-provisioning' | translate }}</ng-template>

11
ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts

@ -85,8 +85,6 @@ export class AddDeviceProfileDialogComponent extends
transportConfigFormGroup: UntypedFormGroup;
alarmRulesFormGroup: UntypedFormGroup;
provisionConfigFormGroup: UntypedFormGroup;
serviceType = ServiceType.TB_RULE_ENGINE;
@ -132,12 +130,6 @@ export class AddDeviceProfileDialogComponent extends
this.deviceProfileTransportTypeChanged();
});
this.alarmRulesFormGroup = this.fb.group(
{
alarms: [null]
}
);
this.provisionConfigFormGroup = this.fb.group(
{
provisionConfiguration: [{
@ -176,8 +168,6 @@ export class AddDeviceProfileDialogComponent extends
case 1:
return this.transportConfigFormGroup;
case 2:
return this.alarmRulesFormGroup;
case 3:
return this.provisionConfigFormGroup;
}
}
@ -199,7 +189,6 @@ export class AddDeviceProfileDialogComponent extends
profileData: {
configuration: createDeviceProfileConfiguration(DeviceProfileType.DEFAULT),
transportConfiguration: this.transportConfigFormGroup.get('transportConfiguration').value,
alarms: this.alarmRulesFormGroup.get('alarms').value,
provisionConfiguration: deviceProvisionConfiguration
}
};

44
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,
}
}
]
}
];

12
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts

@ -301,6 +301,18 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit
this.entityRequiredText = 'ai-models.model-required';
this.notFoundEntities = 'ai-models.no-model-text';
break;
case EntityType.DEVICE_PROFILE:
this.entityText = 'device-profile.device-profile';
this.noEntitiesMatchingText = 'device-profile.no-device-profiles-matching';
this.entityRequiredText = 'device-profile.device-profile-required';
this.notFoundEntities = 'device-profile.no-device-profiles-text';
break;
case EntityType.ASSET_PROFILE:
this.entityText = 'asset-profile.asset-profile';
this.noEntitiesMatchingText = 'asset-profile.no-asset-profiles-matching';
this.entityRequiredText = 'asset-profile.asset-profile-required';
this.notFoundEntities = 'asset-profile.no-asset-profiles-text';
break;
case AliasEntityType.CURRENT_CUSTOMER:
this.entityText = 'customer.default-customer';
this.noEntitiesMatchingText = 'customer.no-customers-matching';

23
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>([
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<PageData<EntitySubtype>>;
let subTypesCfPagesObservable: Observable<PageData<string>>;
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);
}

2
ui-ngx/src/app/shared/components/entity/entity-type-select.component.html

@ -16,7 +16,7 @@
-->
<mat-form-field [formGroup]="entityTypeFormGroup"
[subscriptSizing]="inlineField ? 'dynamic' : 'fixed'"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"
[appearance]="inlineField ? 'outline' : appearance">
<mat-label *ngIf="showLabel && !inlineField">{{ label }}</mat-label>
<mat-select [required]="required" formControlName="entityType">

5
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;

7
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<T extends AlarmRuleFilterPredicate>
}
export type ComplexAlarmRuleFilterPredicate = BaseComplexFilterPredicate<AlarmRuleFilterPredicate>;
export interface AlarmRuleFilterConfig {
name?: Array<string>;
entityType?: EntityType;
entities?: Array<string>;
}

7
ui-ngx/src/app/shared/models/calculated-field.models.ts

@ -1051,3 +1051,10 @@ export function uniqueNameValidator(existingNames: string[]): ValidatorFn {
return namesSet.has(newName) ? { duplicateName: true } : null;
};
}
export interface CalculatedFieldsQuery {
type: CalculatedFieldType;
entityType?: EntityType;
entities?: Array<string>;
name?: string;
}

21
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -1360,12 +1360,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.",
@ -1430,7 +1436,7 @@
"schedule-days-of-week-required": "At least one day of week should be selected.",
"expression-type": {
"simple": "Simple",
"tbel": "TBEL"
"script": "Script"
},
"operation-type": {
"and": "And",
@ -1468,8 +1474,8 @@
"condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.",
"condition-repeating-value-pattern": "Count of events should be integers.",
"condition-repeating-value-required": "Count of events is required.",
"create-alarm-rules": "Create alarm rules",
"clear-alarm-rule": "Clear alarm rule",
"create-conditions": "Create conditions",
"clear-condition": "Clear condition",
"no-clear-alarm-rule": "No clear condition configured",
"advanced-settings": "Advanced settings",
"propagate-alarm": "Propagate alarm to related entities",
@ -1477,7 +1483,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",

Loading…
Cancel
Save